Add Toon serializer: LLM-optimized format & logging
Introduced AcToonSerializer, a new high-performance, LLM-friendly serialization format (Toon) with explicit meta/types/data sections, rich type metadata, and smart inference for property documentation. Added ToonDescriptionAttribute for custom schema annotations and relationship metadata. Implemented fallback/placeholder system merging custom, Microsoft, and inferred metadata. Enhanced logging: AcLoggerBase now implements ILogger, with provider/extensions for DI integration. Updated SignalR client to use AcLogger. Added ToonExtendedInfo.txt with full documentation. No breaking changes to existing serialization.
This commit is contained in:
commit
223036f8e9
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||||
AyCode.Core.targets = AyCode.Core.targets
|
AyCode.Core.targets = AyCode.Core.targets
|
||||||
RunQuickBenchmark.bat = RunQuickBenchmark.bat
|
RunQuickBenchmark.bat = RunQuickBenchmark.bat
|
||||||
RunQuickBenchmark.ps1 = RunQuickBenchmark.ps1
|
RunQuickBenchmark.ps1 = RunQuickBenchmark.ps1
|
||||||
|
ToonExtendedInfo.txt = ToonExtendedInfo.txt
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Benchmark", "AyCode.Benchmark\AyCode.Benchmark.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AyCode.Benchmark", "AyCode.Benchmark\AyCode.Benchmark.csproj", "{A20861A9-411E-6150-BF5C-69E8196E5D22}"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using AyCode.Core.Serializers.Toons;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
@ -11,6 +12,7 @@ namespace AyCode.Core.Compression;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BrotliHelper
|
public static class BrotliHelper
|
||||||
{
|
{
|
||||||
|
//[ToonDescription("Unique identifier for the person")]
|
||||||
private const int DefaultBufferSize = 4096;
|
private const int DefaultBufferSize = 4096;
|
||||||
private const int MaxStackAllocSize = 1024;
|
private const int MaxStackAllocSize = 1024;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace AyCode.Core.Loggers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ILoggerProvider implementation that creates logger instances using AcLoggerBase.
|
||||||
|
/// Since AcLoggerBase now implements ILogger directly, this provider just wraps
|
||||||
|
/// the base logger with category-specific instances.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AcLoggerProvider<TLogger> : ILoggerProvider where TLogger : AcLoggerBase
|
||||||
|
{
|
||||||
|
private readonly Func<string, TLogger> _loggerFactory;
|
||||||
|
private readonly ConcurrentDictionary<string, TLogger> _loggers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a provider that uses a factory function to create category-specific loggers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">Factory function that creates a logger for a given category name.</param>
|
||||||
|
public AcLoggerProvider(Func<string, TLogger> loggerFactory)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ILogger CreateLogger(string categoryName)
|
||||||
|
{
|
||||||
|
return _loggers.GetOrAdd(categoryName, _loggerFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_loggers.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for registering AcLogger with Microsoft's DI and logging infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
public static class AcLoggerExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds AcLogger as a logging provider using a factory function.
|
||||||
|
/// The factory receives the category name and should return a configured logger instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// builder.Logging.AddAcLogger(categoryName => new MyLogger(categoryName));
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static ILoggingBuilder AddAcLogger<TLogger>(this ILoggingBuilder builder, Func<string, TLogger> loggerFactory)
|
||||||
|
where TLogger : AcLoggerBase
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||||
|
|
||||||
|
builder.AddProvider(new AcLoggerProvider<TLogger>(loggerFactory));
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears default providers and adds only AcLogger.
|
||||||
|
/// Use this if you want ONLY your logger, not Microsoft's console/debug loggers.
|
||||||
|
/// </summary>
|
||||||
|
public static ILoggingBuilder UseOnlyAcLogger<TLogger>(this ILoggingBuilder builder, Func<string, TLogger> loggerFactory)
|
||||||
|
where TLogger : AcLoggerBase
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||||
|
|
||||||
|
builder.ClearProviders();
|
||||||
|
builder.AddProvider(new AcLoggerProvider<TLogger>(loggerFactory));
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,10 @@ using System.Security.AccessControl;
|
||||||
using AyCode.Core.Consts;
|
using AyCode.Core.Consts;
|
||||||
using AyCode.Core.Enums;
|
using AyCode.Core.Enums;
|
||||||
using AyCode.Utils.Extensions;
|
using AyCode.Utils.Extensions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using static System.Net.Mime.MediaTypeNames;
|
using static System.Net.Mime.MediaTypeNames;
|
||||||
|
using AcLogLevel = AyCode.Core.Loggers.LogLevel;
|
||||||
|
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
|
||||||
|
|
||||||
namespace AyCode.Core.Loggers;
|
namespace AyCode.Core.Loggers;
|
||||||
|
|
||||||
|
|
@ -12,11 +15,18 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
{
|
{
|
||||||
protected readonly List<IAcLogWriterBase> LogWriters = [];
|
protected readonly List<IAcLogWriterBase> LogWriters = [];
|
||||||
|
|
||||||
public LogLevel LogLevel { get; set; } = LogLevel.Error;
|
public AcLogLevel LogLevel { get; set; } = AcLogLevel.Error;
|
||||||
public AppType AppType { get; set; } = AppType.Server;
|
public AppType AppType { get; set; } = AppType.Server;
|
||||||
|
|
||||||
public string? CategoryName { get; set; }
|
public string? CategoryName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If true, long category names (e.g., "Microsoft.AspNetCore.SignalR.HubConnectionHandler")
|
||||||
|
/// will be shortened to just the class name (e.g., "HubConnectionHandler").
|
||||||
|
/// Default is true for consistency with typeof(T).Name usage.
|
||||||
|
/// </summary>
|
||||||
|
public bool ShortenCategoryNames { get; set; } = true;
|
||||||
|
|
||||||
protected AcLoggerBase() : this(null)
|
protected AcLoggerBase() : this(null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +36,7 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
CategoryName = categoryName ?? "...";
|
CategoryName = categoryName ?? "...";
|
||||||
|
|
||||||
AppType = AcEnv.AppConfiguration.GetEnum<AppType>("AyCode:Logger:AppType");
|
AppType = AcEnv.AppConfiguration.GetEnum<AppType>("AyCode:Logger:AppType");
|
||||||
LogLevel = AcEnv.AppConfiguration.GetEnum<LogLevel>("AyCode:Logger:LogLevel");
|
LogLevel = AcEnv.AppConfiguration.GetEnum<AcLogLevel>("AyCode:Logger:LogLevel");
|
||||||
|
|
||||||
foreach (var logWriterSection in AcEnv.AppConfiguration.GetSection("AyCode:Logger:LogWriters").GetChildren())
|
foreach (var logWriterSection in AcEnv.AppConfiguration.GetSection("AyCode:Logger:LogWriters").GetChildren())
|
||||||
{
|
{
|
||||||
|
|
@ -41,7 +51,7 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var logWriterLogLevel = logWriterSection.GetEnum<LogLevel>("LogLevel");
|
var logWriterLogLevel = logWriterSection.GetEnum<AcLogLevel>("LogLevel");
|
||||||
|
|
||||||
if (Activator.CreateInstance(logWriterType, AppType, logWriterLogLevel, CategoryName) is IAcLogWriterBase logWriter) LogWriters.Add(logWriter);
|
if (Activator.CreateInstance(logWriterType, AppType, logWriterLogLevel, CategoryName) is IAcLogWriterBase logWriter) LogWriters.Add(logWriter);
|
||||||
else Console.Error.WriteLine($"{GetType().Name}; Can't create logWriterType instance; logWriterType: {logWriterType?.AssemblyQualifiedName};");
|
else Console.Error.WriteLine($"{GetType().Name}; Can't create logWriterType instance; logWriterType: {logWriterType?.AssemblyQualifiedName};");
|
||||||
|
|
@ -54,11 +64,11 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
protected AcLoggerBase(string? categoryName, params IAcLogWriterBase[] logWriters) :
|
protected AcLoggerBase(string? categoryName, params IAcLogWriterBase[] logWriters) :
|
||||||
this(AcEnv.AppConfiguration.GetEnum<AppType>("AyCode:Logger:AppType"), AcEnv.AppConfiguration.GetEnum<LogLevel>("AyCode:Logger:LogLevel"), categoryName, logWriters)
|
this(AcEnv.AppConfiguration.GetEnum<AppType>("AyCode:Logger:AppType"), AcEnv.AppConfiguration.GetEnum<AcLogLevel>("AyCode:Logger:LogLevel"), categoryName, logWriters)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
protected AcLoggerBase(AppType appType, LogLevel logLevel, string? categoryName, params IAcLogWriterBase[] logWriters)
|
protected AcLoggerBase(AppType appType, AcLogLevel logLevel, string? categoryName, params IAcLogWriterBase[] logWriters)
|
||||||
{
|
{
|
||||||
AppType = appType;
|
AppType = appType;
|
||||||
LogLevel = logLevel;
|
LogLevel = logLevel;
|
||||||
|
|
@ -72,9 +82,11 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
public List<IAcLogWriterBase> GetWriters => [.. LogWriters];
|
public List<IAcLogWriterBase> GetWriters => [.. LogWriters];
|
||||||
public TLogWriter? Writer<TLogWriter>() where TLogWriter : IAcLogWriterBase => LogWriters.OfType<TLogWriter>().FirstOrDefault();
|
public TLogWriter? Writer<TLogWriter>() where TLogWriter : IAcLogWriterBase => LogWriters.OfType<TLogWriter>().FirstOrDefault();
|
||||||
|
|
||||||
|
#region IAcLogWriterBase Implementation
|
||||||
|
|
||||||
public virtual void Detail(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
public virtual void Detail(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||||
{
|
{
|
||||||
if (LogLevel <= LogLevel.Detail) LogWriters.ForEach(x => x.Detail(text, categoryName ?? CategoryName, memberName));
|
if (LogLevel <= AcLogLevel.Detail) LogWriters.ForEach(x => x.Detail(text, categoryName ?? CategoryName, memberName));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
|
|
@ -83,7 +95,7 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
|
|
||||||
public virtual void Debug(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
public virtual void Debug(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||||
{
|
{
|
||||||
if (LogLevel <= LogLevel.Debug) LogWriters.ForEach(x => x.Debug(text, categoryName ?? CategoryName, memberName));
|
if (LogLevel <= AcLogLevel.Debug) LogWriters.ForEach(x => x.Debug(text, categoryName ?? CategoryName, memberName));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
|
|
@ -92,7 +104,7 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
|
|
||||||
public virtual void Info(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
public virtual void Info(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||||
{
|
{
|
||||||
if (LogLevel <= LogLevel.Info) LogWriters.ForEach(x => x.Info(text, categoryName ?? CategoryName, memberName));
|
if (LogLevel <= AcLogLevel.Info) LogWriters.ForEach(x => x.Info(text, categoryName ?? CategoryName, memberName));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
|
|
@ -101,7 +113,7 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
|
|
||||||
public virtual void Warning(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
public virtual void Warning(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||||
{
|
{
|
||||||
if (LogLevel <= LogLevel.Warning) LogWriters.ForEach(x => x.Warning(text, categoryName ?? CategoryName, memberName));
|
if (LogLevel <= AcLogLevel.Warning) LogWriters.ForEach(x => x.Warning(text, categoryName ?? CategoryName, memberName));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
|
|
@ -110,7 +122,7 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
|
|
||||||
public virtual void Suggest(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
public virtual void Suggest(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||||
{
|
{
|
||||||
if (LogLevel <= LogLevel.Suggest) LogWriters.ForEach(x => x.Suggest(text, categoryName ?? CategoryName, memberName));
|
if (LogLevel <= AcLogLevel.Suggest) LogWriters.ForEach(x => x.Suggest(text, categoryName ?? CategoryName, memberName));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
|
|
@ -119,37 +131,36 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
|
|
||||||
public virtual void Error(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
public virtual void Error(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||||
{
|
{
|
||||||
if (LogLevel <= LogLevel.Error) LogWriters.ForEach(x => x.Error(text, ex, categoryName ?? CategoryName, memberName));
|
if (LogLevel <= AcLogLevel.Error) LogWriters.ForEach(x => x.Error(text, ex, categoryName ?? CategoryName, memberName));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
public void ErrorConditional(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
public void ErrorConditional(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null)
|
||||||
=> Error(text, ex, categoryName, memberName);
|
=> Error(text, ex, categoryName, memberName);
|
||||||
|
|
||||||
public void Write(AppType appType, LogLevel logLevel, string? logText, string? callerMemberName, string? categoryName)
|
public void Write(AppType appType, AcLogLevel logLevel, string? logText, string? callerMemberName, string? categoryName)
|
||||||
=> Write(appType, logLevel, logText, callerMemberName, categoryName, null, null);
|
=> Write(appType, logLevel, logText, callerMemberName, categoryName, null, null);
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
public void WriteConditional(AppType appType, LogLevel logLevel, string? logText, string? callerMemberName, string? categoryName)
|
public void WriteConditional(AppType appType, AcLogLevel logLevel, string? logText, string? callerMemberName, string? categoryName)
|
||||||
=> Write(appType, logLevel, logText, callerMemberName, categoryName, null, null);
|
=> Write(appType, logLevel, logText, callerMemberName, categoryName, null, null);
|
||||||
|
|
||||||
public void Write(AppType appType, LogLevel logLevel, string? logText, string? callerMemberName, string? categoryName, Exception? ex)
|
public void Write(AppType appType, AcLogLevel logLevel, string? logText, string? callerMemberName, string? categoryName, Exception? ex)
|
||||||
=> Write(appType, logLevel, logText, callerMemberName, categoryName, ex?.GetType().Name, ex?.ToString());
|
=> Write(appType, logLevel, logText, callerMemberName, categoryName, ex?.GetType().Name, ex?.ToString());
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
public void WriteConditional(AppType appType, LogLevel logLevel, string? logText, string? callerMemberName, string? categoryName, Exception? ex)
|
public void WriteConditional(AppType appType, AcLogLevel logLevel, string? logText, string? callerMemberName, string? categoryName, Exception? ex)
|
||||||
=> Write(appType, logLevel, logText, callerMemberName, categoryName, ex);
|
=> Write(appType, logLevel, logText, callerMemberName, categoryName, ex);
|
||||||
|
|
||||||
public virtual void Write(AppType appType, LogLevel logLevel, string? logText, string? callerMemberName, string? categoryName, string? errorType, string? exMessage)
|
public virtual void Write(AppType appType, AcLogLevel logLevel, string? logText, string? callerMemberName, string? categoryName, string? errorType, string? exMessage)
|
||||||
{
|
{
|
||||||
if (LogLevel <= logLevel) LogWriters.ForEach(x => x.Write(appType, logLevel, logText, callerMemberName, categoryName ?? CategoryName, errorType, exMessage));
|
if (LogLevel <= logLevel) LogWriters.ForEach(x => x.Write(appType, logLevel, logText, callerMemberName, categoryName ?? CategoryName, errorType, exMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
public void WriteConditional(AppType appType, LogLevel logLevel, string? logText, string? callerMemberName, string? categoryName, string? errorType, string? exMessage)
|
public void WriteConditional(AppType appType, AcLogLevel logLevel, string? logText, string? callerMemberName, string? categoryName, string? errorType, string? exMessage)
|
||||||
=> Write(appType, logLevel, logText, callerMemberName, categoryName, errorType, exMessage);
|
=> Write(appType, logLevel, logText, callerMemberName, categoryName, errorType, exMessage);
|
||||||
|
|
||||||
|
|
||||||
public void Write(IAcLogItemClient logItem)
|
public void Write(IAcLogItemClient logItem)
|
||||||
{
|
{
|
||||||
if (LogLevel <= logItem.LogLevel) LogWriters.ForEach(x => x.Write(logItem));
|
if (LogLevel <= logItem.LogLevel) LogWriters.ForEach(x => x.Write(logItem));
|
||||||
|
|
@ -157,4 +168,114 @@ public abstract class AcLoggerBase : IAcLoggerBase
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
public void WriteConditional(IAcLogItemClient logItem) => Write(logItem);
|
public void WriteConditional(IAcLogItemClient logItem) => Write(logItem);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ILogger Implementation
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ILogger.BeginScope - AcLoggerBase doesn't support scopes, returns no-op disposable.
|
||||||
|
/// </summary>
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ILogger.IsEnabled - Checks if the specified Microsoft log level is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled(MsLogLevel logLevel)
|
||||||
|
{
|
||||||
|
var acLogLevel = MapFromMsLogLevel(logLevel);
|
||||||
|
return LogLevel <= acLogLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ILogger.Log - Main logging method called by Microsoft services.
|
||||||
|
/// </summary>
|
||||||
|
public void Log<TState>(MsLogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
if (!IsEnabled(logLevel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var message = formatter(state, exception);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(message) && exception == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
//var fullMessage = eventId.Id != 0
|
||||||
|
// ? $"[{eventId.Id}:{eventId.Name}] {message}"
|
||||||
|
// : message;
|
||||||
|
|
||||||
|
var fullMessage = message;
|
||||||
|
var category = ShortenCategoryNames ? GetShortCategoryName(CategoryName) : CategoryName;
|
||||||
|
|
||||||
|
// Use eventId.Name as memberName if available, otherwise null (will show as empty, not "Log")
|
||||||
|
var memberName = !string.IsNullOrEmpty(eventId.Name) ? $"{eventId.Name}:{eventId.Id}" : "Log";
|
||||||
|
|
||||||
|
switch (logLevel)
|
||||||
|
{
|
||||||
|
case MsLogLevel.Trace:
|
||||||
|
Detail(fullMessage, category, memberName);
|
||||||
|
break;
|
||||||
|
case MsLogLevel.Debug:
|
||||||
|
Debug(fullMessage, category, memberName);
|
||||||
|
break;
|
||||||
|
case MsLogLevel.Information:
|
||||||
|
Info(fullMessage, category, memberName);
|
||||||
|
break;
|
||||||
|
case MsLogLevel.Warning:
|
||||||
|
Warning(fullMessage, category, memberName);
|
||||||
|
break;
|
||||||
|
case MsLogLevel.Error:
|
||||||
|
Error(fullMessage, exception, category, memberName);
|
||||||
|
break;
|
||||||
|
case MsLogLevel.Critical:
|
||||||
|
Error($"[CRITICAL] {fullMessage}", exception, category, memberName);
|
||||||
|
break;
|
||||||
|
case MsLogLevel.None:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shortens a fully qualified type name to just the class name.
|
||||||
|
/// E.g., "Microsoft.AspNetCore.SignalR.HubConnectionHandler" -> "HubConnectionHandler"
|
||||||
|
/// </summary>
|
||||||
|
private static string? GetShortCategoryName(string? categoryName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(categoryName))
|
||||||
|
return categoryName;
|
||||||
|
|
||||||
|
var lastDot = categoryName.LastIndexOf('.');
|
||||||
|
return lastDot >= 0 ? categoryName[(lastDot + 1)..] : categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps Microsoft.Extensions.Logging.LogLevel to AyCode.Core.Loggers.LogLevel.
|
||||||
|
/// </summary>
|
||||||
|
private static AcLogLevel MapFromMsLogLevel(MsLogLevel msLogLevel)
|
||||||
|
{
|
||||||
|
return msLogLevel switch
|
||||||
|
{
|
||||||
|
MsLogLevel.Trace => AcLogLevel.Detail,
|
||||||
|
MsLogLevel.Debug => AcLogLevel.Debug,
|
||||||
|
MsLogLevel.Information => AcLogLevel.Info,
|
||||||
|
MsLogLevel.Warning => AcLogLevel.Warning,
|
||||||
|
MsLogLevel.Error => AcLogLevel.Error,
|
||||||
|
MsLogLevel.Critical => AcLogLevel.Error,
|
||||||
|
MsLogLevel.None => AcLogLevel.Disabled,
|
||||||
|
_ => AcLogLevel.Info
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-op scope implementation for ILogger.BeginScope.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static NullScope Instance { get; } = new();
|
||||||
|
private NullScope() { }
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
namespace AyCode.Core.Loggers;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
public interface IAcLoggerBase : IAcLogWriterBase
|
namespace AyCode.Core.Loggers;
|
||||||
|
|
||||||
|
public interface IAcLoggerBase : IAcLogWriterBase, ILogger
|
||||||
{
|
{
|
||||||
public List<IAcLogWriterBase> GetWriters { get; }
|
public List<IAcLogWriterBase> GetWriters { get; }
|
||||||
public TLogWriter? Writer<TLogWriter>() where TLogWriter : IAcLogWriterBase;
|
public TLogWriter? Writer<TLogWriter>() where TLogWriter : IAcLogWriterBase;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
using System.Buffers;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ public enum AcSerializerType : byte
|
||||||
{
|
{
|
||||||
Json = 0,
|
Json = 0,
|
||||||
Binary = 1,
|
Binary = 1,
|
||||||
|
Toon = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace AyCode.Core.Serializers.Toons;
|
||||||
|
|
||||||
|
public static partial class AcToonSerializer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extract constraints from C# type system (value types, nullables, numeric ranges).
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractTypeConstraints(Type type)
|
||||||
|
{
|
||||||
|
var constraints = new List<string>();
|
||||||
|
|
||||||
|
var underlyingType = Nullable.GetUnderlyingType(type);
|
||||||
|
var isNullable = underlyingType != null || !type.IsValueType;
|
||||||
|
|
||||||
|
// Nullable vs Required
|
||||||
|
if (isNullable)
|
||||||
|
constraints.Add("nullable");
|
||||||
|
else
|
||||||
|
constraints.Add("required");
|
||||||
|
|
||||||
|
var baseType = underlyingType ?? type;
|
||||||
|
|
||||||
|
// Numeric type ranges
|
||||||
|
var typeCode = Type.GetTypeCode(baseType);
|
||||||
|
switch (typeCode)
|
||||||
|
{
|
||||||
|
case TypeCode.Byte:
|
||||||
|
constraints.Add("range: 0-255");
|
||||||
|
break;
|
||||||
|
case TypeCode.SByte:
|
||||||
|
constraints.Add("range: -128-127");
|
||||||
|
break;
|
||||||
|
case TypeCode.Int16:
|
||||||
|
constraints.Add("range: -32768-32767");
|
||||||
|
break;
|
||||||
|
case TypeCode.UInt16:
|
||||||
|
constraints.Add("range: 0-65535");
|
||||||
|
break;
|
||||||
|
case TypeCode.Int32:
|
||||||
|
constraints.Add("numeric");
|
||||||
|
break;
|
||||||
|
case TypeCode.UInt32:
|
||||||
|
constraints.Add("range: 0-4294967295");
|
||||||
|
break;
|
||||||
|
case TypeCode.Int64:
|
||||||
|
constraints.Add("numeric");
|
||||||
|
break;
|
||||||
|
case TypeCode.UInt64:
|
||||||
|
constraints.Add("numeric, non-negative");
|
||||||
|
break;
|
||||||
|
case TypeCode.Single:
|
||||||
|
case TypeCode.Double:
|
||||||
|
case TypeCode.Decimal:
|
||||||
|
constraints.Add("numeric");
|
||||||
|
break;
|
||||||
|
case TypeCode.Boolean:
|
||||||
|
constraints.Add("boolean: true|false");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum values
|
||||||
|
if (baseType.IsEnum)
|
||||||
|
{
|
||||||
|
var values = string.Join("|", Enum.GetNames(baseType).Take(5)); // Limit to 5 values
|
||||||
|
var more = Enum.GetNames(baseType).Length > 5 ? "..." : "";
|
||||||
|
constraints.Add($"enum: {values}{more}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(", ", constraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract constraints from Microsoft DataAnnotations attributes.
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractDataAnnotationConstraints(PropertyInfo prop)
|
||||||
|
{
|
||||||
|
var constraints = new List<string>();
|
||||||
|
|
||||||
|
// [Required]
|
||||||
|
if (prop.GetCustomAttribute<RequiredAttribute>() != null)
|
||||||
|
constraints.Add("required");
|
||||||
|
|
||||||
|
// [Range]
|
||||||
|
var range = prop.GetCustomAttribute<RangeAttribute>();
|
||||||
|
if (range != null)
|
||||||
|
constraints.Add($"range: {range.Minimum}-{range.Maximum}");
|
||||||
|
|
||||||
|
// [MaxLength]
|
||||||
|
var maxLen = prop.GetCustomAttribute<MaxLengthAttribute>();
|
||||||
|
if (maxLen != null)
|
||||||
|
constraints.Add($"max-length: {maxLen.Length}");
|
||||||
|
|
||||||
|
// [MinLength]
|
||||||
|
var minLen = prop.GetCustomAttribute<MinLengthAttribute>();
|
||||||
|
if (minLen != null)
|
||||||
|
constraints.Add($"min-length: {minLen.Length}");
|
||||||
|
|
||||||
|
// [StringLength]
|
||||||
|
var strLen = prop.GetCustomAttribute<StringLengthAttribute>();
|
||||||
|
if (strLen != null)
|
||||||
|
{
|
||||||
|
if (strLen.MinimumLength > 0)
|
||||||
|
constraints.Add($"length: {strLen.MinimumLength}-{strLen.MaximumLength}");
|
||||||
|
else
|
||||||
|
constraints.Add($"max-length: {strLen.MaximumLength}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// [EmailAddress]
|
||||||
|
if (prop.GetCustomAttribute<EmailAddressAttribute>() != null)
|
||||||
|
constraints.Add("email-format");
|
||||||
|
|
||||||
|
// [Phone]
|
||||||
|
if (prop.GetCustomAttribute<PhoneAttribute>() != null)
|
||||||
|
constraints.Add("phone-format");
|
||||||
|
|
||||||
|
// [Url]
|
||||||
|
if (prop.GetCustomAttribute<UrlAttribute>() != null)
|
||||||
|
constraints.Add("url-format");
|
||||||
|
|
||||||
|
// [CreditCard]
|
||||||
|
if (prop.GetCustomAttribute<CreditCardAttribute>() != null)
|
||||||
|
constraints.Add("credit-card-format");
|
||||||
|
|
||||||
|
// [RegularExpression]
|
||||||
|
var regex = prop.GetCustomAttribute<RegularExpressionAttribute>();
|
||||||
|
if (regex != null)
|
||||||
|
constraints.Add($"pattern: {regex.Pattern}");
|
||||||
|
|
||||||
|
return string.Join(", ", constraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merge constraints with priority: custom > ms > inferred > type.
|
||||||
|
/// Handles deduplication and cleanup.
|
||||||
|
/// </summary>
|
||||||
|
private static string MergeConstraints(
|
||||||
|
string? typeConstraints,
|
||||||
|
string? msConstraints,
|
||||||
|
string? inferredConstraints,
|
||||||
|
string? customConstraints)
|
||||||
|
{
|
||||||
|
var all = new HashSet<string>();
|
||||||
|
|
||||||
|
// Add in reverse priority order (lower priority first)
|
||||||
|
AddConstraintsToSet(all, typeConstraints);
|
||||||
|
AddConstraintsToSet(all, inferredConstraints);
|
||||||
|
AddConstraintsToSet(all, msConstraints);
|
||||||
|
AddConstraintsToSet(all, customConstraints);
|
||||||
|
|
||||||
|
return string.Join(", ", all.OrderBy(GetConstraintPriority));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddConstraintsToSet(HashSet<string> set, string? constraints)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(constraints)) return;
|
||||||
|
|
||||||
|
foreach (var c in constraints.Split(',').Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)))
|
||||||
|
{
|
||||||
|
// Remove duplicates based on constraint type
|
||||||
|
var constraintType = c.Split(':')[0].Trim();
|
||||||
|
|
||||||
|
// Remove existing constraints of same type (e.g., old range before adding new range)
|
||||||
|
set.RemoveWhere(x => x.Split(':')[0].Trim().Equals(constraintType, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
set.Add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetConstraintPriority(string constraint)
|
||||||
|
{
|
||||||
|
var lower = constraint.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Priority order
|
||||||
|
if (lower.StartsWith("required")) return 1;
|
||||||
|
if (lower.StartsWith("nullable")) return 2;
|
||||||
|
if (lower.StartsWith("range:")) return 3;
|
||||||
|
if (lower.StartsWith("length:")) return 4;
|
||||||
|
if (lower.StartsWith("max-length:")) return 5;
|
||||||
|
if (lower.StartsWith("min-length:")) return 6;
|
||||||
|
if (lower.Contains("-format")) return 7;
|
||||||
|
if (lower.StartsWith("pattern:")) return 8;
|
||||||
|
if (lower.StartsWith("enum:")) return 9;
|
||||||
|
|
||||||
|
return 100; // Other constraints last
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve [#...] placeholders in description string.
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveDescriptionPlaceholders(
|
||||||
|
string template,
|
||||||
|
ToonPropertyAccessor prop,
|
||||||
|
Type declaringType)
|
||||||
|
{
|
||||||
|
var result = template;
|
||||||
|
|
||||||
|
// [#Description] → Microsoft [Description]
|
||||||
|
if (result.Contains("[#Description]"))
|
||||||
|
{
|
||||||
|
var msDesc = prop.PropertyInfo.GetCustomAttribute<DescriptionAttribute>();
|
||||||
|
var value = msDesc?.Description ?? "";
|
||||||
|
result = result.Replace("[#Description]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#DisplayName] → Microsoft [DisplayName]
|
||||||
|
if (result.Contains("[#DisplayName]"))
|
||||||
|
{
|
||||||
|
var displayName = prop.PropertyInfo.GetCustomAttribute<DisplayNameAttribute>();
|
||||||
|
var value = displayName?.DisplayName ?? prop.Name;
|
||||||
|
result = result.Replace("[#DisplayName]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#SmartDescription] → Smart inference
|
||||||
|
if (result.Contains("[#SmartDescription]"))
|
||||||
|
{
|
||||||
|
var value = GetPropertyDescription(declaringType, prop.Name, prop.PropertyType);
|
||||||
|
result = result.Replace("[#SmartDescription]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CleanupPlaceholders(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve [#...] placeholders in purpose string.
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolvePurposePlaceholders(
|
||||||
|
string template,
|
||||||
|
ToonPropertyAccessor prop,
|
||||||
|
Type declaringType)
|
||||||
|
{
|
||||||
|
var result = template;
|
||||||
|
|
||||||
|
// [#SmartPurpose] → Smart inference
|
||||||
|
if (result.Contains("[#SmartPurpose]"))
|
||||||
|
{
|
||||||
|
var value = GetPropertyPurpose(declaringType, prop.Name);
|
||||||
|
result = result.Replace("[#SmartPurpose]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CleanupPlaceholders(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve [#...] placeholders in constraints string.
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveConstraintPlaceholders(
|
||||||
|
string template,
|
||||||
|
ToonPropertyAccessor prop)
|
||||||
|
{
|
||||||
|
var result = template;
|
||||||
|
|
||||||
|
// [#Range]
|
||||||
|
if (result.Contains("[#Range]"))
|
||||||
|
{
|
||||||
|
var attr = prop.PropertyInfo.GetCustomAttribute<RangeAttribute>();
|
||||||
|
var value = attr != null ? $"range: {attr.Minimum}-{attr.Maximum}" : "";
|
||||||
|
result = result.Replace("[#Range]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#Required]
|
||||||
|
if (result.Contains("[#Required]"))
|
||||||
|
{
|
||||||
|
var value = prop.PropertyInfo.GetCustomAttribute<RequiredAttribute>() != null ? "required" : "";
|
||||||
|
result = result.Replace("[#Required]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#MaxLength]
|
||||||
|
if (result.Contains("[#MaxLength]"))
|
||||||
|
{
|
||||||
|
var attr = prop.PropertyInfo.GetCustomAttribute<MaxLengthAttribute>();
|
||||||
|
var value = attr != null ? $"max-length: {attr.Length}" : "";
|
||||||
|
result = result.Replace("[#MaxLength]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#MinLength]
|
||||||
|
if (result.Contains("[#MinLength]"))
|
||||||
|
{
|
||||||
|
var attr = prop.PropertyInfo.GetCustomAttribute<MinLengthAttribute>();
|
||||||
|
var value = attr != null ? $"min-length: {attr.Length}" : "";
|
||||||
|
result = result.Replace("[#MinLength]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#StringLength]
|
||||||
|
if (result.Contains("[#StringLength]"))
|
||||||
|
{
|
||||||
|
var attr = prop.PropertyInfo.GetCustomAttribute<StringLengthAttribute>();
|
||||||
|
string value = "";
|
||||||
|
if (attr != null)
|
||||||
|
{
|
||||||
|
value = attr.MinimumLength > 0
|
||||||
|
? $"length: {attr.MinimumLength}-{attr.MaximumLength}"
|
||||||
|
: $"max-length: {attr.MaximumLength}";
|
||||||
|
}
|
||||||
|
result = result.Replace("[#StringLength]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#EmailAddress]
|
||||||
|
if (result.Contains("[#EmailAddress]"))
|
||||||
|
{
|
||||||
|
var value = prop.PropertyInfo.GetCustomAttribute<EmailAddressAttribute>() != null ? "email-format" : "";
|
||||||
|
result = result.Replace("[#EmailAddress]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#Phone]
|
||||||
|
if (result.Contains("[#Phone]"))
|
||||||
|
{
|
||||||
|
var value = prop.PropertyInfo.GetCustomAttribute<PhoneAttribute>() != null ? "phone-format" : "";
|
||||||
|
result = result.Replace("[#Phone]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#Url]
|
||||||
|
if (result.Contains("[#Url]"))
|
||||||
|
{
|
||||||
|
var value = prop.PropertyInfo.GetCustomAttribute<UrlAttribute>() != null ? "url-format" : "";
|
||||||
|
result = result.Replace("[#Url]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#CreditCard]
|
||||||
|
if (result.Contains("[#CreditCard]"))
|
||||||
|
{
|
||||||
|
var value = prop.PropertyInfo.GetCustomAttribute<CreditCardAttribute>() != null ? "credit-card-format" : "";
|
||||||
|
result = result.Replace("[#CreditCard]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#RegularExpression]
|
||||||
|
if (result.Contains("[#RegularExpression]"))
|
||||||
|
{
|
||||||
|
var attr = prop.PropertyInfo.GetCustomAttribute<RegularExpressionAttribute>();
|
||||||
|
var value = attr != null ? $"pattern: {attr.Pattern}" : "";
|
||||||
|
result = result.Replace("[#RegularExpression]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#SmartTypeConstraints] → Type-derived constraints
|
||||||
|
if (result.Contains("[#SmartTypeConstraints]"))
|
||||||
|
{
|
||||||
|
var value = ExtractTypeConstraints(prop.PropertyType);
|
||||||
|
result = result.Replace("[#SmartTypeConstraints]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [#SmartInferenceConstraints] → Smart inference
|
||||||
|
if (result.Contains("[#SmartInferenceConstraints]"))
|
||||||
|
{
|
||||||
|
var value = GetInferredConstraints(prop.PropertyType, prop.Name);
|
||||||
|
result = result.Replace("[#SmartInferenceConstraints]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CleanupPlaceholders(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve [#...] placeholders in examples string.
|
||||||
|
/// </summary>
|
||||||
|
private static string ResolveExamplesPlaceholders(
|
||||||
|
string template,
|
||||||
|
ToonPropertyAccessor prop)
|
||||||
|
{
|
||||||
|
var result = template;
|
||||||
|
|
||||||
|
// [#GeneratedExample] → Generate example based on type
|
||||||
|
if (result.Contains("[#GeneratedExample]"))
|
||||||
|
{
|
||||||
|
var value = GenerateExampleValue(prop.PropertyType);
|
||||||
|
result = result.Replace("[#GeneratedExample]", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CleanupPlaceholders(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clean up empty placeholders and formatting artifacts.
|
||||||
|
/// </summary>
|
||||||
|
private static string CleanupPlaceholders(string text)
|
||||||
|
{
|
||||||
|
// Remove unresolved placeholders
|
||||||
|
text = Regex.Replace(text, @"\[#\w+\]", "");
|
||||||
|
|
||||||
|
// Remove double commas
|
||||||
|
text = Regex.Replace(text, @",\s*,", ",");
|
||||||
|
|
||||||
|
// Remove leading/trailing commas
|
||||||
|
text = Regex.Replace(text, @"^,\s*", "");
|
||||||
|
text = Regex.Replace(text, @",\s*$", "");
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
text = Regex.Replace(text, @"\s{2,}", " ");
|
||||||
|
|
||||||
|
return text.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generate example value for a type.
|
||||||
|
/// </summary>
|
||||||
|
private static string GenerateExampleValue(Type type)
|
||||||
|
{
|
||||||
|
var typeCode = Type.GetTypeCode(type);
|
||||||
|
return typeCode switch
|
||||||
|
{
|
||||||
|
TypeCode.Int32 => "42",
|
||||||
|
TypeCode.Int64 => "1000",
|
||||||
|
TypeCode.String => "example",
|
||||||
|
TypeCode.Boolean => "true",
|
||||||
|
TypeCode.DateTime => DateTime.Now.ToString("yyyy-MM-dd"),
|
||||||
|
TypeCode.Decimal => "99.99",
|
||||||
|
TypeCode.Double => "3.14",
|
||||||
|
TypeCode.Single => "2.71",
|
||||||
|
TypeCode.Byte => "255",
|
||||||
|
_ => "value"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get inferred constraints based on property name patterns.
|
||||||
|
/// </summary>
|
||||||
|
private static string GetInferredConstraints(Type propertyType, string propertyName)
|
||||||
|
{
|
||||||
|
var constraints = new List<string>();
|
||||||
|
var baseType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
|
||||||
|
|
||||||
|
// Email
|
||||||
|
if (baseType == typeof(string) && propertyName.Contains("Email", StringComparison.OrdinalIgnoreCase))
|
||||||
|
constraints.Add("email-format");
|
||||||
|
|
||||||
|
// URL
|
||||||
|
if (baseType == typeof(string) && propertyName.Contains("Url", StringComparison.OrdinalIgnoreCase))
|
||||||
|
constraints.Add("url-format");
|
||||||
|
|
||||||
|
// Age
|
||||||
|
if (IsIntegerType(baseType) && propertyName.Contains("Age", StringComparison.OrdinalIgnoreCase))
|
||||||
|
constraints.Add("range: 0-150");
|
||||||
|
|
||||||
|
// Count (non-negative)
|
||||||
|
if (IsIntegerType(baseType) && propertyName.EndsWith("Count", StringComparison.OrdinalIgnoreCase))
|
||||||
|
constraints.Add("non-negative");
|
||||||
|
|
||||||
|
return string.Join(", ", constraints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,482 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using static AyCode.Core.Helpers.JsonUtilities;
|
||||||
|
|
||||||
|
namespace AyCode.Core.Serializers.Toons;
|
||||||
|
|
||||||
|
public static partial class AcToonSerializer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Write data section only (for DataOnly mode).
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteDataSectionOnly(object value, Type type, ToonSerializationContext context)
|
||||||
|
{
|
||||||
|
WriteDataSection(value, type, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write @data section.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteDataSection(object value, Type type, ToonSerializationContext context)
|
||||||
|
{
|
||||||
|
context.WriteLine("@data {");
|
||||||
|
context.CurrentIndentLevel++;
|
||||||
|
|
||||||
|
WriteValue(value, type, context, 0);
|
||||||
|
|
||||||
|
context.CurrentIndentLevel--;
|
||||||
|
context.WriteLine("}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write a value (dispatcher for different types).
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteValue(object? value, Type type, ToonSerializationContext context, int depth)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
context.Write("null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try primitive first
|
||||||
|
if (TryWritePrimitive(value, type, context))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (depth > context.MaxDepth)
|
||||||
|
{
|
||||||
|
context.Write("null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reference
|
||||||
|
if (context.UseReferenceHandling && context.TryGetExistingRef(value, out var refId))
|
||||||
|
{
|
||||||
|
context.Write($"@ref:{refId}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dictionaries
|
||||||
|
if (value is IDictionary dictionary)
|
||||||
|
{
|
||||||
|
WriteDictionary(dictionary, context, depth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle collections/arrays
|
||||||
|
if (value is IEnumerable enumerable && !ReferenceEquals(type, StringType))
|
||||||
|
{
|
||||||
|
WriteArray(enumerable, type, context, depth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle complex objects
|
||||||
|
WriteObject(value, type, context, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write primitive value inline.
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static bool TryWritePrimitive(object value, Type type, ToonSerializationContext context)
|
||||||
|
{
|
||||||
|
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
|
||||||
|
var typeCode = Type.GetTypeCode(underlyingType);
|
||||||
|
|
||||||
|
string valueStr;
|
||||||
|
string? typeHint = null;
|
||||||
|
|
||||||
|
switch (typeCode)
|
||||||
|
{
|
||||||
|
case TypeCode.String:
|
||||||
|
var strValue = (string)value;
|
||||||
|
// Check if multi-line string format should be used
|
||||||
|
if (context.Options.UseMultiLineStrings &&
|
||||||
|
strValue.Length > context.Options.MultiLineStringThreshold)
|
||||||
|
{
|
||||||
|
context.Write(FormatMultiLineString(strValue, context));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
valueStr = EscapeString(strValue);
|
||||||
|
typeHint = "string";
|
||||||
|
break;
|
||||||
|
case TypeCode.Int32:
|
||||||
|
valueStr = ((int)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "int32";
|
||||||
|
break;
|
||||||
|
case TypeCode.Int64:
|
||||||
|
valueStr = ((long)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "int64";
|
||||||
|
break;
|
||||||
|
case TypeCode.Boolean:
|
||||||
|
valueStr = (bool)value ? "true" : "false";
|
||||||
|
typeHint = "bool";
|
||||||
|
break;
|
||||||
|
case TypeCode.Double:
|
||||||
|
var d = (double)value;
|
||||||
|
if (double.IsNaN(d) || double.IsInfinity(d))
|
||||||
|
{
|
||||||
|
valueStr = "null";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
valueStr = d.ToString("G17", CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "float64";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TypeCode.Decimal:
|
||||||
|
valueStr = ((decimal)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "decimal";
|
||||||
|
break;
|
||||||
|
case TypeCode.Single:
|
||||||
|
var f = (float)value;
|
||||||
|
if (float.IsNaN(f) || float.IsInfinity(f))
|
||||||
|
{
|
||||||
|
valueStr = "null";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
valueStr = f.ToString("G9", CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "float32";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TypeCode.DateTime:
|
||||||
|
valueStr = $"\"{((DateTime)value).ToString("O", CultureInfo.InvariantCulture)}\"";
|
||||||
|
typeHint = "datetime";
|
||||||
|
break;
|
||||||
|
case TypeCode.Byte:
|
||||||
|
valueStr = ((byte)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "byte";
|
||||||
|
break;
|
||||||
|
case TypeCode.Int16:
|
||||||
|
valueStr = ((short)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "int16";
|
||||||
|
break;
|
||||||
|
case TypeCode.UInt16:
|
||||||
|
valueStr = ((ushort)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "uint16";
|
||||||
|
break;
|
||||||
|
case TypeCode.UInt32:
|
||||||
|
valueStr = ((uint)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "uint32";
|
||||||
|
break;
|
||||||
|
case TypeCode.UInt64:
|
||||||
|
valueStr = ((ulong)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "uint64";
|
||||||
|
break;
|
||||||
|
case TypeCode.SByte:
|
||||||
|
valueStr = ((sbyte)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
typeHint = "sbyte";
|
||||||
|
break;
|
||||||
|
case TypeCode.Char:
|
||||||
|
valueStr = EscapeString(value.ToString()!);
|
||||||
|
typeHint = "char";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Check special types
|
||||||
|
if (ReferenceEquals(underlyingType, GuidType))
|
||||||
|
{
|
||||||
|
valueStr = $"\"{((Guid)value).ToString("D")}\"";
|
||||||
|
typeHint = "guid";
|
||||||
|
}
|
||||||
|
else if (ReferenceEquals(underlyingType, DateTimeOffsetType))
|
||||||
|
{
|
||||||
|
valueStr = $"\"{((DateTimeOffset)value).ToString("O", CultureInfo.InvariantCulture)}\"";
|
||||||
|
typeHint = "datetimeoffset";
|
||||||
|
}
|
||||||
|
else if (ReferenceEquals(underlyingType, TimeSpanType))
|
||||||
|
{
|
||||||
|
valueStr = $"\"{((TimeSpan)value).ToString("c", CultureInfo.InvariantCulture)}\"";
|
||||||
|
typeHint = "timespan";
|
||||||
|
}
|
||||||
|
else if (underlyingType.IsEnum)
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Write(valueStr);
|
||||||
|
|
||||||
|
// Add inline type hint if enabled
|
||||||
|
if (context.Options.UseInlineTypeHints && typeHint != null)
|
||||||
|
{
|
||||||
|
context.Write($" <{typeHint}>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write complex object.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteObject(object value, Type type, ToonSerializationContext context, int depth)
|
||||||
|
{
|
||||||
|
var metadata = GetTypeMetadata(type);
|
||||||
|
|
||||||
|
// Write reference ID if this is a multi-referenced object
|
||||||
|
if (context.UseReferenceHandling && context.ShouldWriteRef(value, out var refId))
|
||||||
|
{
|
||||||
|
context.Write($"@{refId} ");
|
||||||
|
context.MarkAsWritten(value, refId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write type name if enabled
|
||||||
|
if (context.Options.WriteTypeNames)
|
||||||
|
{
|
||||||
|
context.Write(metadata.ShortTypeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.WriteLine(" {");
|
||||||
|
context.CurrentIndentLevel++;
|
||||||
|
|
||||||
|
var nextDepth = depth + 1;
|
||||||
|
|
||||||
|
// Write properties
|
||||||
|
foreach (var prop in metadata.Properties)
|
||||||
|
{
|
||||||
|
var propValue = prop.GetValue(value);
|
||||||
|
|
||||||
|
// Skip null/default values if option is set
|
||||||
|
if (context.Options.OmitDefaultValues && prop.IsDefaultValue(propValue))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
context.WriteIndent();
|
||||||
|
context.Write(prop.Name);
|
||||||
|
|
||||||
|
// Token optimization: no spaces around '=' when indentation is disabled
|
||||||
|
if (context.Options.UseIndentation)
|
||||||
|
{
|
||||||
|
context.Write(" = ");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Write('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propValue == null)
|
||||||
|
{
|
||||||
|
context.WriteLine("null");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteValue(propValue, prop.PropertyType, context, nextDepth);
|
||||||
|
context.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.CurrentIndentLevel--;
|
||||||
|
context.WriteIndent();
|
||||||
|
context.Write("}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write array/collection.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteArray(IEnumerable enumerable, Type type, ToonSerializationContext context, int depth)
|
||||||
|
{
|
||||||
|
var elementType = GetCollectionElementType(type) ?? typeof(object);
|
||||||
|
|
||||||
|
// Get count if possible
|
||||||
|
var count = 0;
|
||||||
|
if (enumerable is ICollection collection)
|
||||||
|
{
|
||||||
|
count = collection.Count;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fallback: enumerate to count
|
||||||
|
foreach (var _ in enumerable) count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write array header with optional type hint and count
|
||||||
|
if (context.Options.ShowCollectionCount)
|
||||||
|
{
|
||||||
|
var elementTypeName = GetTypeDisplayName(elementType);
|
||||||
|
context.Write($"<{elementTypeName}[]> (count: {count}) ");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.WriteLine("[");
|
||||||
|
context.CurrentIndentLevel++;
|
||||||
|
|
||||||
|
var nextDepth = depth + 1;
|
||||||
|
|
||||||
|
foreach (var item in enumerable)
|
||||||
|
{
|
||||||
|
context.WriteIndent();
|
||||||
|
WriteValue(item, item?.GetType() ?? elementType, context, nextDepth);
|
||||||
|
context.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.CurrentIndentLevel--;
|
||||||
|
context.WriteIndent();
|
||||||
|
context.Write("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get type display name for a type (used in array hints).
|
||||||
|
/// </summary>
|
||||||
|
private static string GetTypeDisplayName(Type type)
|
||||||
|
{
|
||||||
|
var typeCode = Type.GetTypeCode(type);
|
||||||
|
return typeCode switch
|
||||||
|
{
|
||||||
|
TypeCode.Int32 => "int32",
|
||||||
|
TypeCode.Int64 => "int64",
|
||||||
|
TypeCode.Double => "float64",
|
||||||
|
TypeCode.Single => "float32",
|
||||||
|
TypeCode.Decimal => "decimal",
|
||||||
|
TypeCode.Boolean => "bool",
|
||||||
|
TypeCode.String => "string",
|
||||||
|
TypeCode.DateTime => "datetime",
|
||||||
|
TypeCode.Byte => "byte",
|
||||||
|
TypeCode.Int16 => "int16",
|
||||||
|
TypeCode.UInt16 => "uint16",
|
||||||
|
TypeCode.UInt32 => "uint32",
|
||||||
|
TypeCode.UInt64 => "uint64",
|
||||||
|
TypeCode.SByte => "sbyte",
|
||||||
|
TypeCode.Char => "char",
|
||||||
|
_ => type.Name.ToLowerInvariant()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format multi-line string with triple-quote syntax.
|
||||||
|
/// </summary>
|
||||||
|
private static string FormatMultiLineString(string value, ToonSerializationContext context)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("\"\"\"");
|
||||||
|
|
||||||
|
if (context.Options.UseIndentation)
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
// Split into lines and indent each
|
||||||
|
var lines = value.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
for (var i = 0; i <= context.CurrentIndentLevel; i++)
|
||||||
|
{
|
||||||
|
sb.Append(context.Options.IndentString);
|
||||||
|
}
|
||||||
|
sb.AppendLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing quotes with proper indentation
|
||||||
|
for (var i = 0; i < context.CurrentIndentLevel; i++)
|
||||||
|
{
|
||||||
|
sb.Append(context.Options.IndentString);
|
||||||
|
}
|
||||||
|
sb.Append("\"\"\"");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(value);
|
||||||
|
sb.Append("\"\"\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write dictionary.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteDictionary(IDictionary dictionary, ToonSerializationContext context, int depth)
|
||||||
|
{
|
||||||
|
// Write dictionary header with count and type information
|
||||||
|
if (context.Options.ShowCollectionCount)
|
||||||
|
{
|
||||||
|
var dictType = dictionary.GetType();
|
||||||
|
var typeDisplayName = GetDictionaryTypeDisplayName(dictType);
|
||||||
|
context.Write($"<{typeDisplayName}> (count: {dictionary.Count}) ");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.WriteLine("{");
|
||||||
|
context.CurrentIndentLevel++;
|
||||||
|
|
||||||
|
var nextDepth = depth + 1;
|
||||||
|
|
||||||
|
foreach (DictionaryEntry entry in dictionary)
|
||||||
|
{
|
||||||
|
context.WriteIndent();
|
||||||
|
|
||||||
|
// Write key
|
||||||
|
var keyType = entry.Key?.GetType() ?? typeof(object);
|
||||||
|
WriteValue(entry.Key, keyType, context, nextDepth);
|
||||||
|
|
||||||
|
context.Write(" => ");
|
||||||
|
|
||||||
|
// Write value
|
||||||
|
var valueType = entry.Value?.GetType() ?? typeof(object);
|
||||||
|
WriteValue(entry.Value, valueType, context, nextDepth);
|
||||||
|
|
||||||
|
context.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.CurrentIndentLevel--;
|
||||||
|
context.WriteIndent();
|
||||||
|
context.Write("}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get type display name for dictionary (e.g., "Dictionary<string, decimal>").
|
||||||
|
/// </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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,760 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
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\"");
|
||||||
|
|
||||||
|
// 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('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Write("[");
|
||||||
|
var first = true;
|
||||||
|
foreach (var t in typesToDocument)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
context.WriteLine("@types {");
|
||||||
|
context.CurrentIndentLevel++;
|
||||||
|
|
||||||
|
foreach (var t in typesToDocument)
|
||||||
|
{
|
||||||
|
WriteTypeDefinition(t, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.CurrentIndentLevel--;
|
||||||
|
context.WriteLine("}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collect all types that need documentation (recursive).
|
||||||
|
/// </summary>
|
||||||
|
private static void CollectTypes(Type type, HashSet<Type> types)
|
||||||
|
{
|
||||||
|
if (IsPrimitiveOrStringFast(type)) return;
|
||||||
|
|
||||||
|
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
|
||||||
|
|
||||||
|
if (!types.Add(underlyingType)) return; // Already processed
|
||||||
|
|
||||||
|
// Handle collections
|
||||||
|
var elementType = GetCollectionElementType(underlyingType);
|
||||||
|
if (elementType != null)
|
||||||
|
{
|
||||||
|
CollectTypes(elementType, types);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dictionaries
|
||||||
|
if (IsDictionaryType(underlyingType, out var keyType, out var valueType))
|
||||||
|
{
|
||||||
|
if (keyType != null) CollectTypes(keyType, types);
|
||||||
|
if (valueType != null) CollectTypes(valueType, types);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle object properties
|
||||||
|
var metadata = GetTypeMetadata(underlyingType);
|
||||||
|
foreach (var prop in metadata.Properties)
|
||||||
|
{
|
||||||
|
CollectTypes(prop.PropertyType, types);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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}\"");
|
||||||
|
|
||||||
|
// 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.WriteIndentedLine($"purpose: \"{typePurpose}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.CurrentIndentLevel--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.Properties.Length == 0) return;
|
||||||
|
|
||||||
|
// Property descriptions
|
||||||
|
context.CurrentIndentLevel++;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Detect relationship metadata
|
||||||
|
var relationshipMetadata = DetectRelationshipMetadata(prop.PropertyInfo, prop.CustomDescription);
|
||||||
|
|
||||||
|
var typeHint = prop.TypeDisplayName;
|
||||||
|
|
||||||
|
if (context.Options.UseEnhancedMetadata)
|
||||||
|
{
|
||||||
|
// Enhanced format with constraints and purpose
|
||||||
|
context.WriteIndentedLine($"{prop.Name}: {typeHint}");
|
||||||
|
context.CurrentIndentLevel++;
|
||||||
|
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
|
||||||
|
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
|
||||||
|
var relationshipHint = GetRelationshipHint(relationshipMetadata);
|
||||||
|
var fullDescription = string.IsNullOrEmpty(relationshipHint)
|
||||||
|
? propDescription
|
||||||
|
: $"{propDescription} ({relationshipHint})";
|
||||||
|
context.WriteIndentedLine($"{prop.Name}: {typeHint} \"{fullDescription}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = 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>
|
||||||
|
/// 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);
|
||||||
|
if (metadata.IsCollection)
|
||||||
|
return $"Collection of {metadata.ElementType?.Name ?? "items"}";
|
||||||
|
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
|
||||||
|
if (GetCollectionElementType(propertyType) != null)
|
||||||
|
return $"Collection of {GetCollectionElementType(propertyType)?.Name ?? "items"} 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] > Smart inference
|
||||||
|
/// </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. Smart inference (fallback)
|
||||||
|
return GetPropertyDescription(declaringType, prop.Name, prop.PropertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return MergeConstraints(typeConstraints, null, inferredConstraints, resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No placeholder → custom wins (replace mode)
|
||||||
|
return customConstraints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No custom constraint → Fallback chain
|
||||||
|
var msConstraints = ExtractDataAnnotationConstraints(prop.PropertyInfo);
|
||||||
|
var typeConstraints2 = ExtractTypeConstraints(prop.PropertyType);
|
||||||
|
var inferredConstraints2 = GetInferredConstraints(prop.PropertyType, prop.Name);
|
||||||
|
|
||||||
|
return MergeConstraints(typeConstraints2, msConstraints, inferredConstraints2, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// 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>
|
||||||
|
/// 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>
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using ReferenceEqualityComparer = AyCode.Core.Serializers.Jsons.ReferenceEqualityComparer;
|
||||||
|
|
||||||
|
namespace AyCode.Core.Serializers.Toons;
|
||||||
|
|
||||||
|
public static partial class AcToonSerializer
|
||||||
|
{
|
||||||
|
#region Context Pool
|
||||||
|
|
||||||
|
private static class ToonSerializationContextPool
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentQueue<ToonSerializationContext> Pool = new();
|
||||||
|
private const int MaxPoolSize = 16;
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static ToonSerializationContext Get(AcToonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (Pool.TryDequeue(out var context))
|
||||||
|
{
|
||||||
|
context.Reset(options);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
return new ToonSerializationContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static void Return(ToonSerializationContext context)
|
||||||
|
{
|
||||||
|
if (Pool.Count < MaxPoolSize)
|
||||||
|
{
|
||||||
|
context.Clear();
|
||||||
|
Pool.Enqueue(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Serialization Context
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pooled context for Toon serialization.
|
||||||
|
/// Handles output building, indentation, and reference tracking.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ToonSerializationContext
|
||||||
|
{
|
||||||
|
private readonly StringBuilder _builder;
|
||||||
|
private Dictionary<object, int>? _scanOccurrences;
|
||||||
|
private Dictionary<object, int>? _writtenRefs;
|
||||||
|
private HashSet<object>? _multiReferenced;
|
||||||
|
private HashSet<Type>? _registeredTypes;
|
||||||
|
private int _nextRefId;
|
||||||
|
|
||||||
|
public AcToonSerializerOptions Options { get; private set; }
|
||||||
|
public int CurrentIndentLevel { get; set; }
|
||||||
|
public bool UseReferenceHandling { get; private set; }
|
||||||
|
public byte MaxDepth { get; private set; }
|
||||||
|
|
||||||
|
public ToonSerializationContext(AcToonSerializerOptions options)
|
||||||
|
{
|
||||||
|
_builder = new StringBuilder(4096);
|
||||||
|
Options = options;
|
||||||
|
Reset(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset(AcToonSerializerOptions options)
|
||||||
|
{
|
||||||
|
Options = options;
|
||||||
|
UseReferenceHandling = options.UseReferenceHandling;
|
||||||
|
MaxDepth = options.MaxDepth;
|
||||||
|
CurrentIndentLevel = 0;
|
||||||
|
_nextRefId = 1;
|
||||||
|
|
||||||
|
if (UseReferenceHandling)
|
||||||
|
{
|
||||||
|
_scanOccurrences ??= new Dictionary<object, int>(64, ReferenceEqualityComparer.Instance);
|
||||||
|
_writtenRefs ??= new Dictionary<object, int>(32, ReferenceEqualityComparer.Instance);
|
||||||
|
_multiReferenced ??= new HashSet<object>(32, ReferenceEqualityComparer.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
_registeredTypes ??= new HashSet<Type>(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_builder.Clear();
|
||||||
|
_scanOccurrences?.Clear();
|
||||||
|
_writtenRefs?.Clear();
|
||||||
|
_multiReferenced?.Clear();
|
||||||
|
_registeredTypes?.Clear();
|
||||||
|
CurrentIndentLevel = 0;
|
||||||
|
_nextRefId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Reference Handling
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public bool TrackForScanning(object obj)
|
||||||
|
{
|
||||||
|
if (_scanOccurrences == null) return true;
|
||||||
|
|
||||||
|
ref var count = ref CollectionsMarshal.GetValueRefOrAddDefault(_scanOccurrences, obj, out var exists);
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
_multiReferenced!.Add(obj);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
count = 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public bool ShouldWriteRef(object obj, out int refId)
|
||||||
|
{
|
||||||
|
if (_multiReferenced != null && _multiReferenced.Contains(obj) && !_writtenRefs!.ContainsKey(obj))
|
||||||
|
{
|
||||||
|
refId = _nextRefId++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
refId = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void MarkAsWritten(object obj, int refId) => _writtenRefs![obj] = refId;
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public bool TryGetExistingRef(object obj, out int refId)
|
||||||
|
{
|
||||||
|
if (_writtenRefs != null && _writtenRefs.TryGetValue(obj, out refId))
|
||||||
|
return true;
|
||||||
|
refId = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Type Registration
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public bool RegisterType(Type type)
|
||||||
|
{
|
||||||
|
if (_registeredTypes == null) return false;
|
||||||
|
return _registeredTypes.Add(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public bool IsTypeRegistered(Type type)
|
||||||
|
{
|
||||||
|
return _registeredTypes?.Contains(type) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Output Methods
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(string text)
|
||||||
|
{
|
||||||
|
_builder.Append(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void Write(char c)
|
||||||
|
{
|
||||||
|
_builder.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void WriteLine()
|
||||||
|
{
|
||||||
|
_builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void WriteLine(string text)
|
||||||
|
{
|
||||||
|
_builder.AppendLine(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteIndent()
|
||||||
|
{
|
||||||
|
if (!Options.UseIndentation) return;
|
||||||
|
|
||||||
|
for (var i = 0; i < CurrentIndentLevel; i++)
|
||||||
|
{
|
||||||
|
_builder.Append(Options.IndentString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteIndentedLine(string text)
|
||||||
|
{
|
||||||
|
WriteIndent();
|
||||||
|
WriteLine(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteProperty(string name, string value, string? inlineComment = null)
|
||||||
|
{
|
||||||
|
WriteIndent();
|
||||||
|
_builder.Append(name);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
_builder.Append(" # ");
|
||||||
|
_builder.Append(inlineComment);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteComment(string comment)
|
||||||
|
{
|
||||||
|
WriteIndent();
|
||||||
|
_builder.Append("# ");
|
||||||
|
_builder.AppendLine(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetResult()
|
||||||
|
{
|
||||||
|
return _builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using static AyCode.Core.Helpers.JsonUtilities;
|
||||||
|
|
||||||
|
namespace AyCode.Core.Serializers.Toons;
|
||||||
|
|
||||||
|
public static partial class AcToonSerializer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cached metadata for a type including properties, type name, and descriptions.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ToonTypeMetadata
|
||||||
|
{
|
||||||
|
public string TypeName { get; }
|
||||||
|
public string ShortTypeName { get; }
|
||||||
|
public ToonPropertyAccessor[] Properties { get; }
|
||||||
|
public bool IsCollection { get; }
|
||||||
|
public bool IsDictionary { get; }
|
||||||
|
public Type? ElementType { get; }
|
||||||
|
public ToonDescriptionAttribute? CustomDescription { get; }
|
||||||
|
|
||||||
|
public ToonTypeMetadata(Type type)
|
||||||
|
{
|
||||||
|
TypeName = type.FullName ?? type.Name;
|
||||||
|
ShortTypeName = type.Name;
|
||||||
|
|
||||||
|
// Get custom attribute if present
|
||||||
|
CustomDescription = type.GetCustomAttribute<ToonDescriptionAttribute>();
|
||||||
|
|
||||||
|
// Check if collection or dictionary
|
||||||
|
IsDictionary = IsDictionaryType(type, out _, out _);
|
||||||
|
if (!IsDictionary)
|
||||||
|
{
|
||||||
|
ElementType = GetCollectionElementType(type);
|
||||||
|
IsCollection = ElementType != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build property accessors
|
||||||
|
if (!IsCollection && !IsDictionary && !IsPrimitiveOrStringFast(type))
|
||||||
|
{
|
||||||
|
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||||
|
.Where(p => p.CanRead &&
|
||||||
|
p.GetIndexParameters().Length == 0 &&
|
||||||
|
!HasJsonIgnoreAttribute(p))
|
||||||
|
.Select(p => new ToonPropertyAccessor(p))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Properties = props;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Properties = Array.Empty<ToonPropertyAccessor>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Property accessor with compiled getter for performance.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ToonPropertyAccessor
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public Type PropertyType { get; }
|
||||||
|
public PropertyInfo PropertyInfo { get; }
|
||||||
|
public TypeCode PropertyTypeCode { get; }
|
||||||
|
public string TypeDisplayName { get; }
|
||||||
|
public bool IsNullable { get; }
|
||||||
|
|
||||||
|
// Custom attribute metadata
|
||||||
|
public ToonDescriptionAttribute? CustomDescription { get; }
|
||||||
|
|
||||||
|
private readonly Func<object, object?> _getter;
|
||||||
|
|
||||||
|
public ToonPropertyAccessor(PropertyInfo prop)
|
||||||
|
{
|
||||||
|
Name = prop.Name;
|
||||||
|
PropertyType = prop.PropertyType;
|
||||||
|
PropertyInfo = prop;
|
||||||
|
|
||||||
|
var underlyingType = Nullable.GetUnderlyingType(PropertyType);
|
||||||
|
IsNullable = underlyingType != null;
|
||||||
|
|
||||||
|
var actualType = underlyingType ?? PropertyType;
|
||||||
|
PropertyTypeCode = Type.GetTypeCode(actualType);
|
||||||
|
|
||||||
|
// Build type display name for meta section
|
||||||
|
TypeDisplayName = BuildTypeDisplayName(PropertyType);
|
||||||
|
|
||||||
|
// Get custom attribute if present
|
||||||
|
CustomDescription = prop.GetCustomAttribute<ToonDescriptionAttribute>();
|
||||||
|
|
||||||
|
// Compile getter for fast access
|
||||||
|
_getter = CreateCompiledGetter(prop.DeclaringType!, prop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Func<object, object?> CreateCompiledGetter(Type declaringType, PropertyInfo prop)
|
||||||
|
{
|
||||||
|
var objParam = Expression.Parameter(typeof(object), "obj");
|
||||||
|
var castExpr = Expression.Convert(objParam, declaringType);
|
||||||
|
var propAccess = Expression.Property(castExpr, prop);
|
||||||
|
var boxed = Expression.Convert(propAccess, typeof(object));
|
||||||
|
return Expression.Lambda<Func<object, object?>>(boxed, objParam).Compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public object? GetValue(object obj) => _getter(obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if value is default/null without boxing value types.
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public bool IsDefaultValue(object? value)
|
||||||
|
{
|
||||||
|
if (value == null) return true;
|
||||||
|
|
||||||
|
switch (PropertyTypeCode)
|
||||||
|
{
|
||||||
|
case TypeCode.Int32: return (int)value == 0;
|
||||||
|
case TypeCode.Int64: return (long)value == 0L;
|
||||||
|
case TypeCode.Double: return (double)value == 0.0;
|
||||||
|
case TypeCode.Decimal: return (decimal)value == 0m;
|
||||||
|
case TypeCode.Single: return (float)value == 0f;
|
||||||
|
case TypeCode.Byte: return (byte)value == 0;
|
||||||
|
case TypeCode.Int16: return (short)value == 0;
|
||||||
|
case TypeCode.UInt16: return (ushort)value == 0;
|
||||||
|
case TypeCode.UInt32: return (uint)value == 0;
|
||||||
|
case TypeCode.UInt64: return (ulong)value == 0;
|
||||||
|
case TypeCode.SByte: return (sbyte)value == 0;
|
||||||
|
case TypeCode.Boolean: return (bool)value == false;
|
||||||
|
case TypeCode.String: return string.IsNullOrEmpty((string)value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PropertyType.IsEnum) return Convert.ToInt32(value) == 0;
|
||||||
|
if (ReferenceEquals(PropertyType, GuidType)) return (Guid)value == Guid.Empty;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build human-readable type name for meta section.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildTypeDisplayName(Type type)
|
||||||
|
{
|
||||||
|
var underlying = Nullable.GetUnderlyingType(type);
|
||||||
|
var isNullable = underlying != null;
|
||||||
|
var actualType = underlying ?? type;
|
||||||
|
|
||||||
|
var baseName = Type.GetTypeCode(actualType) switch
|
||||||
|
{
|
||||||
|
TypeCode.Int32 => "int32",
|
||||||
|
TypeCode.Int64 => "int64",
|
||||||
|
TypeCode.Double => "float64",
|
||||||
|
TypeCode.Decimal => "decimal",
|
||||||
|
TypeCode.Single => "float32",
|
||||||
|
TypeCode.Boolean => "bool",
|
||||||
|
TypeCode.String => "string",
|
||||||
|
TypeCode.DateTime => "datetime",
|
||||||
|
TypeCode.Byte => "byte",
|
||||||
|
TypeCode.Int16 => "int16",
|
||||||
|
TypeCode.UInt16 => "uint16",
|
||||||
|
TypeCode.UInt32 => "uint32",
|
||||||
|
TypeCode.UInt64 => "uint64",
|
||||||
|
TypeCode.SByte => "sbyte",
|
||||||
|
TypeCode.Char => "char",
|
||||||
|
_ => GetComplexTypeName(actualType)
|
||||||
|
};
|
||||||
|
|
||||||
|
return isNullable ? baseName + "?" : baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetComplexTypeName(Type type)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(type, GuidType)) return "guid";
|
||||||
|
if (ReferenceEquals(type, DateTimeOffsetType)) return "datetimeoffset";
|
||||||
|
if (ReferenceEquals(type, TimeSpanType)) return "timespan";
|
||||||
|
if (type.IsEnum) return "enum";
|
||||||
|
|
||||||
|
// Check for collections
|
||||||
|
var elementType = GetCollectionElementType(type);
|
||||||
|
if (elementType != null)
|
||||||
|
{
|
||||||
|
var elementTypeName = BuildTypeDisplayName(elementType);
|
||||||
|
return $"{elementTypeName}[]";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dictionaries
|
||||||
|
if (IsDictionaryType(type, out var keyType, out var valueType))
|
||||||
|
{
|
||||||
|
var keyTypeName = keyType != null ? BuildTypeDisplayName(keyType) : "object";
|
||||||
|
var valueTypeName = valueType != null ? BuildTypeDisplayName(valueType) : "object";
|
||||||
|
return $"dict<{keyTypeName}, {valueTypeName}>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return type.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using static AyCode.Core.Helpers.JsonUtilities;
|
||||||
|
|
||||||
|
namespace AyCode.Core.Serializers.Toons;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// High-performance Toon (Token-Oriented Object Notation) serializer optimized for LLM readability.
|
||||||
|
/// Features:
|
||||||
|
/// - Human-readable indented format
|
||||||
|
/// - Separate @meta/@types and @data sections for token efficiency
|
||||||
|
/// - Type annotations and descriptions for better LLM understanding
|
||||||
|
/// - Reference handling for circular/shared references
|
||||||
|
/// - Optimized for Claude and other LLMs to easily parse and understand data structures
|
||||||
|
/// </summary>
|
||||||
|
public static partial class AcToonSerializer
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<Type, ToonTypeMetadata> TypeMetadataCache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format version for Toon serialization.
|
||||||
|
/// Incremented when breaking changes are made to format.
|
||||||
|
/// </summary>
|
||||||
|
public const string FormatVersion = "1.0";
|
||||||
|
|
||||||
|
#region Public API
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialize object to Toon format with default options.
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static string Serialize<T>(T value) => Serialize(value, AcToonSerializerOptions.Default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialize object to Toon format with specified options.
|
||||||
|
/// </summary>
|
||||||
|
public static string Serialize<T>(T value, AcToonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (value == null) return "null";
|
||||||
|
|
||||||
|
var type = value.GetType();
|
||||||
|
|
||||||
|
// Handle primitive types directly without context
|
||||||
|
if (TrySerializePrimitiveDirect(value, type, out var primitiveResult))
|
||||||
|
return primitiveResult;
|
||||||
|
|
||||||
|
var context = ToonSerializationContextPool.Get(options);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Reference scanning if needed
|
||||||
|
if (options.UseReferenceHandling && !IsPrimitiveOrStringFast(type))
|
||||||
|
{
|
||||||
|
ScanReferences(value, context, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize based on mode
|
||||||
|
switch (options.Mode)
|
||||||
|
{
|
||||||
|
case ToonSerializationMode.MetaOnly:
|
||||||
|
WriteMetaSectionOnly(type, context);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ToonSerializationMode.DataOnly:
|
||||||
|
WriteDataSectionOnly(value, type, context);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ToonSerializationMode.Full:
|
||||||
|
default:
|
||||||
|
WriteMetaSection(type, context);
|
||||||
|
context.WriteLine();
|
||||||
|
WriteDataSection(value, type, context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.GetResult();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ToonSerializationContextPool.Return(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialize only type metadata (schema) for a given type.
|
||||||
|
/// Useful for sending type information once at conversation start.
|
||||||
|
/// </summary>
|
||||||
|
public static string SerializeTypeMetadata<T>() => SerializeTypeMetadata(typeof(T));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialize only type metadata (schema) for a given type.
|
||||||
|
/// </summary>
|
||||||
|
public static string SerializeTypeMetadata(Type type)
|
||||||
|
{
|
||||||
|
var context = ToonSerializationContextPool.Get(AcToonSerializerOptions.MetaOnly);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WriteMetaSectionOnly(type, context);
|
||||||
|
return context.GetResult();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ToonSerializationContextPool.Return(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fast path for primitive types that don't need context.
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static bool TrySerializePrimitiveDirect(object value, Type type, out string result)
|
||||||
|
{
|
||||||
|
var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
|
||||||
|
var typeCode = Type.GetTypeCode(underlyingType);
|
||||||
|
|
||||||
|
switch (typeCode)
|
||||||
|
{
|
||||||
|
case TypeCode.String:
|
||||||
|
result = EscapeString((string)value);
|
||||||
|
return true;
|
||||||
|
case TypeCode.Int32:
|
||||||
|
result = ((int)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.Int64:
|
||||||
|
result = ((long)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.Boolean:
|
||||||
|
result = (bool)value ? "true" : "false";
|
||||||
|
return true;
|
||||||
|
case TypeCode.Double:
|
||||||
|
var d = (double)value;
|
||||||
|
result = double.IsNaN(d) || double.IsInfinity(d) ? "null" : d.ToString("G17", CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.Decimal:
|
||||||
|
result = ((decimal)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.Single:
|
||||||
|
var f = (float)value;
|
||||||
|
result = float.IsNaN(f) || float.IsInfinity(f) ? "null" : f.ToString("G9", CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.DateTime:
|
||||||
|
result = $"\"{((DateTime)value).ToString("O", CultureInfo.InvariantCulture)}\"";
|
||||||
|
return true;
|
||||||
|
case TypeCode.Byte:
|
||||||
|
result = ((byte)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.Int16:
|
||||||
|
result = ((short)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.UInt16:
|
||||||
|
result = ((ushort)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.UInt32:
|
||||||
|
result = ((uint)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.UInt64:
|
||||||
|
result = ((ulong)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.SByte:
|
||||||
|
result = ((sbyte)value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
case TypeCode.Char:
|
||||||
|
result = EscapeString(value.ToString()!);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReferenceEquals(underlyingType, GuidType))
|
||||||
|
{
|
||||||
|
result = $"\"{((Guid)value).ToString("D")}\"";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ReferenceEquals(underlyingType, DateTimeOffsetType))
|
||||||
|
{
|
||||||
|
result = $"\"{((DateTimeOffset)value).ToString("O", CultureInfo.InvariantCulture)}\"";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ReferenceEquals(underlyingType, TimeSpanType))
|
||||||
|
{
|
||||||
|
result = $"\"{((TimeSpan)value).ToString("c", CultureInfo.InvariantCulture)}\"";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (underlyingType.IsEnum)
|
||||||
|
{
|
||||||
|
result = Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = "";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escape string for Toon format (double quotes, newlines, etc).
|
||||||
|
/// </summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static string EscapeString(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return "\"\"";
|
||||||
|
|
||||||
|
// Check if escaping is needed
|
||||||
|
var needsEscaping = false;
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
if (c == '"' || c == '\\' || c == '\n' || c == '\r' || c == '\t' || c < 32)
|
||||||
|
{
|
||||||
|
needsEscaping = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsEscaping)
|
||||||
|
return $"\"{value}\"";
|
||||||
|
|
||||||
|
var sb = new StringBuilder(value.Length + 8);
|
||||||
|
sb.Append('"');
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '"': sb.Append("\\\""); break;
|
||||||
|
case '\\': sb.Append("\\\\"); break;
|
||||||
|
case '\n': sb.Append("\\n"); break;
|
||||||
|
case '\r': sb.Append("\\r"); break;
|
||||||
|
case '\t': sb.Append("\\t"); break;
|
||||||
|
default:
|
||||||
|
if (c < 32)
|
||||||
|
sb.Append($"\\u{((int)c):x4}");
|
||||||
|
else
|
||||||
|
sb.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append('"');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Reference Scanning
|
||||||
|
|
||||||
|
private static void ScanReferences(object? value, ToonSerializationContext context, int depth)
|
||||||
|
{
|
||||||
|
if (value == null || depth > context.MaxDepth) return;
|
||||||
|
|
||||||
|
var type = value.GetType();
|
||||||
|
if (IsPrimitiveOrStringFast(type)) return;
|
||||||
|
if (!context.TrackForScanning(value)) return;
|
||||||
|
|
||||||
|
if (value is IDictionary dictionary)
|
||||||
|
{
|
||||||
|
foreach (DictionaryEntry entry in dictionary)
|
||||||
|
{
|
||||||
|
if (entry.Key != null) ScanReferences(entry.Key, context, depth + 1);
|
||||||
|
if (entry.Value != null) ScanReferences(entry.Value, context, depth + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is IEnumerable enumerable && !ReferenceEquals(type, StringType))
|
||||||
|
{
|
||||||
|
foreach (var item in enumerable)
|
||||||
|
{
|
||||||
|
if (item != null) ScanReferences(item, context, depth + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = GetTypeMetadata(type);
|
||||||
|
foreach (var prop in metadata.Properties)
|
||||||
|
{
|
||||||
|
var propValue = prop.GetValue(value);
|
||||||
|
if (propValue != null) ScanReferences(propValue, context, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Type Metadata
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static ToonTypeMetadata GetTypeMetadata(Type type)
|
||||||
|
=> TypeMetadataCache.GetOrAdd(type, static t => new ToonTypeMetadata(t));
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
using AyCode.Core.Serializers.Jsons;
|
||||||
|
|
||||||
|
namespace AyCode.Core.Serializers.Toons;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls what sections are included in Toon serialization output.
|
||||||
|
/// </summary>
|
||||||
|
public enum ToonSerializationMode : byte
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Include both @meta/@types and @data sections (default).
|
||||||
|
/// Best for first-time serialization or when LLM needs full context.
|
||||||
|
/// </summary>
|
||||||
|
Full = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only include @meta and @types sections, no @data.
|
||||||
|
/// Use to send schema/documentation once at the start of conversation.
|
||||||
|
/// </summary>
|
||||||
|
MetaOnly = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only include @data section, no @meta/@types.
|
||||||
|
/// Use when schema was already sent via MetaOnly - saves tokens.
|
||||||
|
/// </summary>
|
||||||
|
DataOnly = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options for AcToonSerializer - Token-Oriented Object Notation.
|
||||||
|
/// Optimized for LLM readability and token efficiency.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AcToonSerializerOptions : AcSerializerOptions
|
||||||
|
{
|
||||||
|
public override AcSerializerType SerializerType { get; init; } = AcSerializerType.Toon;
|
||||||
|
|
||||||
|
// === META CONTROL ===
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to include type metadata (schema, descriptions, constraints).
|
||||||
|
/// When true, @types section is included with full documentation.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool UseMeta { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialization mode - controls what gets serialized.
|
||||||
|
/// Full = meta + data, MetaOnly = only schema, DataOnly = only values.
|
||||||
|
/// Default: Full
|
||||||
|
/// </summary>
|
||||||
|
public ToonSerializationMode Mode { get; init; } = ToonSerializationMode.Full;
|
||||||
|
|
||||||
|
// === FORMATTING ===
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use indentation for readability.
|
||||||
|
/// When false, output is more compact but harder to read.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool UseIndentation { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indent string (spaces or tabs).
|
||||||
|
/// Default: " " (2 spaces)
|
||||||
|
/// </summary>
|
||||||
|
public string IndentString { get; init; } = " ";
|
||||||
|
|
||||||
|
// === VERBOSITY ===
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Include type hints inline with values (e.g., Age = 30 <int32>).
|
||||||
|
/// Useful for debugging but increases output size.
|
||||||
|
/// Default: false (types only in @types section)
|
||||||
|
/// </summary>
|
||||||
|
public bool UseInlineTypeHints { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Include property descriptions inline as comments.
|
||||||
|
/// Useful for self-documenting output but increases size.
|
||||||
|
/// Default: false (descriptions only in @types section)
|
||||||
|
/// </summary>
|
||||||
|
public bool UseInlineComments { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Show array/collection count in output (e.g., Tags: <string[]> (count: 3)).
|
||||||
|
/// Helps LLM understand collection size at a glance.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowCollectionCount { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use multi-line string format for strings longer than threshold.
|
||||||
|
/// Strings use triple-quote syntax: """..."""
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool UseMultiLineStrings { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum string length to trigger multi-line format.
|
||||||
|
/// Shorter strings remain inline with escaping.
|
||||||
|
/// Default: 80 characters
|
||||||
|
/// </summary>
|
||||||
|
public int MultiLineStringThreshold { get; init; } = 80;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Include enhanced property metadata (constraints, examples, purpose).
|
||||||
|
/// Provides richer context for LLM understanding.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool UseEnhancedMetadata { get; init; } = true;
|
||||||
|
|
||||||
|
// === DATA CONTROL ===
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Omit properties with default/null values.
|
||||||
|
/// Reduces output size significantly for sparse objects.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool OmitDefaultValues { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write type names for root objects (e.g., Person { ... } vs just { ... }).
|
||||||
|
/// Helps LLM understand object types in data section.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool WriteTypeNames { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum string length before truncation in meta examples.
|
||||||
|
/// Default: 50 characters
|
||||||
|
/// </summary>
|
||||||
|
public int MaxExampleStringLength { get; init; } = 50;
|
||||||
|
|
||||||
|
// === PREDEFINED MODES ===
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full mode: Meta + Data (first-time serialization).
|
||||||
|
/// Use when LLM needs complete context about data structure and values.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly AcToonSerializerOptions Default = new()
|
||||||
|
{
|
||||||
|
Mode = ToonSerializationMode.Full,
|
||||||
|
UseMeta = true,
|
||||||
|
UseIndentation = true,
|
||||||
|
OmitDefaultValues = true,
|
||||||
|
WriteTypeNames = true
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Meta-only mode: Only serialize type definitions and descriptions.
|
||||||
|
/// Use this to send schema information once at conversation start.
|
||||||
|
/// Subsequent serializations can use DataOnly mode to save tokens.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly AcToonSerializerOptions MetaOnly = new()
|
||||||
|
{
|
||||||
|
Mode = ToonSerializationMode.MetaOnly,
|
||||||
|
UseMeta = true,
|
||||||
|
UseIndentation = true,
|
||||||
|
UseInlineComments = true
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data-only mode: Only serialize actual data values.
|
||||||
|
/// Use this when schema was already sent via MetaOnly.
|
||||||
|
/// Saves ~30-50% tokens in repeated serializations.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly AcToonSerializerOptions DataOnly = new()
|
||||||
|
{
|
||||||
|
Mode = ToonSerializationMode.DataOnly,
|
||||||
|
UseMeta = false,
|
||||||
|
UseIndentation = true,
|
||||||
|
OmitDefaultValues = true,
|
||||||
|
WriteTypeNames = true
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compact mode: Minimal output, no meta, no indentation.
|
||||||
|
/// Maximum token efficiency but less readable.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly AcToonSerializerOptions Compact = new()
|
||||||
|
{
|
||||||
|
Mode = ToonSerializationMode.DataOnly,
|
||||||
|
UseMeta = false,
|
||||||
|
UseIndentation = false,
|
||||||
|
OmitDefaultValues = true,
|
||||||
|
WriteTypeNames = false,
|
||||||
|
UseReferenceHandling = false
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verbose mode: Everything included (for debugging/documentation).
|
||||||
|
/// Use when you need maximum information and clarity.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly AcToonSerializerOptions Verbose = new()
|
||||||
|
{
|
||||||
|
Mode = ToonSerializationMode.Full,
|
||||||
|
UseMeta = true,
|
||||||
|
UseIndentation = true,
|
||||||
|
UseInlineTypeHints = true,
|
||||||
|
UseInlineComments = true,
|
||||||
|
OmitDefaultValues = false,
|
||||||
|
WriteTypeNames = true
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates options with specified max depth.
|
||||||
|
/// </summary>
|
||||||
|
public static AcToonSerializerOptions WithMaxDepth(byte maxDepth) => new() { MaxDepth = maxDepth };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates options without reference handling (faster, no circular reference support).
|
||||||
|
/// </summary>
|
||||||
|
public static AcToonSerializerOptions WithoutReferenceHandling() => new() { UseReferenceHandling = false };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,300 @@
|
||||||
|
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
|
||||||
|
/// that will be included in the @types section of serialized output.
|
||||||
|
///
|
||||||
|
/// <para><b>KEY FEATURES:</b></para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Partial Support:</b> You can specify only some properties (e.g., just Constraints), others will use fallbacks</item>
|
||||||
|
/// <item><b>Placeholder System:</b> Use [#AttributeName] to reference Microsoft DataAnnotations or smart inference</item>
|
||||||
|
/// <item><b>Fallback Chain:</b> ToonDescription → Microsoft Attributes → Smart Inference (automatic)</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para><b>FALLBACK PRIORITIES:</b></para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><b>Description:</b> ToonDescription.Description → [Description] → Smart Inference</item>
|
||||||
|
/// <item><b>Purpose:</b> ToonDescription.Purpose → Smart Inference</item>
|
||||||
|
/// <item><b>Constraints:</b> ToonDescription.Constraints → [Range]/[Required]/etc → Type Constraints → Smart Inference</item>
|
||||||
|
/// <item><b>Examples:</b> ToonDescription.Examples (no automatic fallback)</item>
|
||||||
|
/// </list>
|
||||||
|
/// </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>
|
||||||
|
/// <code>
|
||||||
|
/// [ToonDescription("User email address",
|
||||||
|
/// Purpose = "Authentication and notifications",
|
||||||
|
/// Constraints = "required, email-format, unique",
|
||||||
|
/// Examples = "user@example.com, admin@company.com")]
|
||||||
|
/// public string Email { get; set; }
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para><b>2. PARTIAL (only some properties, rest auto-filled):</b></para>
|
||||||
|
/// <code>
|
||||||
|
/// [Description("Contact email")] // Microsoft attribute
|
||||||
|
/// [Required]
|
||||||
|
/// [EmailAddress]
|
||||||
|
/// [ToonDescription(Constraints = "[#Required], [#EmailAddress], unique")] // Only constraints specified
|
||||||
|
/// public string Email { get; set; }
|
||||||
|
/// // Result:
|
||||||
|
/// // Description: "Contact email" (from Microsoft [Description])
|
||||||
|
/// // Constraints: "required, email-format, unique" (merged with placeholders)
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para><b>3. PLACEHOLDER APPEND MODE (merge with Microsoft attributes):</b></para>
|
||||||
|
/// <code>
|
||||||
|
/// [Range(0, 150)]
|
||||||
|
/// [Required]
|
||||||
|
/// [ToonDescription(Constraints = "[#Required], [#Range], verified-by-admin")]
|
||||||
|
/// public int Age { get; set; }
|
||||||
|
/// // Result: "required, range: 0-150, verified-by-admin" (merged)
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para><b>4. REPLACE MODE (no placeholders = full override):</b></para>
|
||||||
|
/// <code>
|
||||||
|
/// [Range(0, 150)] // This is IGNORED
|
||||||
|
/// [ToonDescription(Constraints = "custom-validation-only")]
|
||||||
|
/// public int Score { get; set; }
|
||||||
|
/// // Result: "custom-validation-only" (Microsoft attributes ignored)
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para><b>5. FULL AUTOMATIC (no ToonDescription at all):</b></para>
|
||||||
|
/// <code>
|
||||||
|
/// [Required]
|
||||||
|
/// [EmailAddress]
|
||||||
|
/// [MaxLength(100)]
|
||||||
|
/// public string Email { get; set; }
|
||||||
|
/// // Result:
|
||||||
|
/// // Description: "Email address" (smart inference)
|
||||||
|
/// // Constraints: "required, email-format, max-length: 100" (from Microsoft attributes)
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para><b>6. COMBINING PLACEHOLDERS IN DESCRIPTION:</b></para>
|
||||||
|
/// <code>
|
||||||
|
/// [DisplayName("User Email")]
|
||||||
|
/// [Description("Contact email address")]
|
||||||
|
/// [ToonDescription(Description = "[#DisplayName]: [#Description] (primary contact)")]
|
||||||
|
/// public string Email { get; set; }
|
||||||
|
/// // Result: "User Email: Contact email address (primary contact)"
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para><b>7. CLASS-LEVEL WITH PLACEHOLDERS AND FALLBACK:</b></para>
|
||||||
|
/// <code>
|
||||||
|
/// // With Microsoft attribute
|
||||||
|
/// [Description("Base user entity")]
|
||||||
|
/// [ToonDescription("[#Description] with extended functionality",
|
||||||
|
/// Purpose = "Manages user authentication and authorization")]
|
||||||
|
/// public class User { }
|
||||||
|
/// // Result:
|
||||||
|
/// // Description: "Base user entity with extended functionality"
|
||||||
|
/// // Purpose: "Manages user authentication and authorization"
|
||||||
|
///
|
||||||
|
/// // With inheritance (Inherited = true)
|
||||||
|
/// [ToonDescription("Admin user account",
|
||||||
|
/// Purpose = "Administrative users with elevated privileges")]
|
||||||
|
/// public class AdminUser : User { }
|
||||||
|
/// // Result: "Admin user account" (own description)
|
||||||
|
///
|
||||||
|
/// public class SuperUser : AdminUser { }
|
||||||
|
/// // Result: "Admin user account" (inherited from AdminUser)
|
||||||
|
///
|
||||||
|
/// // Without any ToonDescription
|
||||||
|
/// public class GuestUser : User { }
|
||||||
|
/// // Result: "Object of type GuestUser" (smart inference)
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// <para><b>BEST PRACTICES:</b></para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Use placeholders ([#...]) when you want to MERGE with existing Microsoft attributes</item>
|
||||||
|
/// <item>Omit placeholders when you want to REPLACE (full custom control)</item>
|
||||||
|
/// <item>Leave properties empty to use automatic fallbacks</item>
|
||||||
|
/// <item>Combine placeholders with custom text for rich, DRY documentation</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
///
|
||||||
|
/// <example>
|
||||||
|
/// <para><b>COMPLETE EXAMPLE:</b></para>
|
||||||
|
/// <code>
|
||||||
|
/// [ToonDescription("Represents a user account in the system",
|
||||||
|
/// Purpose = "User authentication and profile management")]
|
||||||
|
/// public class Person
|
||||||
|
/// {
|
||||||
|
/// // Full custom
|
||||||
|
/// [ToonDescription("Unique identifier",
|
||||||
|
/// Purpose = "Primary key / database identity",
|
||||||
|
/// Constraints = "required, auto-increment, positive")]
|
||||||
|
/// public int Id { get; set; }
|
||||||
|
///
|
||||||
|
/// // Merge with Microsoft attributes
|
||||||
|
/// [Range(18, 120)]
|
||||||
|
/// [Required]
|
||||||
|
/// [ToonDescription(Constraints = "[#Required], [#Range], verified")]
|
||||||
|
/// public int Age { get; set; }
|
||||||
|
/// // Result: "required, range: 18-120, verified"
|
||||||
|
///
|
||||||
|
/// // Partial - only constraints, rest auto-filled
|
||||||
|
/// [Description("Contact email")]
|
||||||
|
/// [EmailAddress]
|
||||||
|
/// [ToonDescription(Constraints = "[#EmailAddress], unique")]
|
||||||
|
/// public string Email { get; set; }
|
||||||
|
/// // Description: "Contact email" (from Microsoft)
|
||||||
|
/// // Constraints: "email-format, unique" (merged)
|
||||||
|
///
|
||||||
|
/// // Fully automatic - no ToonDescription
|
||||||
|
/// [Required]
|
||||||
|
/// [MaxLength(100)]
|
||||||
|
/// public string Name { get; set; }
|
||||||
|
/// // Description: "Name of the Person" (smart inference)
|
||||||
|
/// // Constraints: "required, max-length: 100" (from Microsoft attributes)
|
||||||
|
///
|
||||||
|
/// // Placeholder in description
|
||||||
|
/// [DisplayName("Account Balance")]
|
||||||
|
/// [ToonDescription(Description = "[#DisplayName] in USD",
|
||||||
|
/// Constraints = "range: 0-999999, decimal(18,2)",
|
||||||
|
/// Examples = "0.00, 1234.56, 999999.99")]
|
||||||
|
/// public decimal Balance { get; set; }
|
||||||
|
/// // Description: "Account Balance in USD"
|
||||||
|
/// }
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
|
||||||
|
public sealed class ToonDescriptionAttribute : Attribute
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the human-readable description of the property or type.
|
||||||
|
/// This appears in the @types section to help LLMs understand the data structure.
|
||||||
|
/// </summary>
|
||||||
|
public string Description { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the purpose of this property (what it's used for).
|
||||||
|
/// Examples: "Primary key", "User authentication", "Audit trail".
|
||||||
|
/// </summary>
|
||||||
|
public string? Purpose { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the constraints or validation rules for this property.
|
||||||
|
/// Examples: "required, email-format", "range: 0-150", "max-length: 100".
|
||||||
|
/// </summary>
|
||||||
|
public string? Constraints { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets example values for this property.
|
||||||
|
/// Helps LLMs understand the expected format and content.
|
||||||
|
/// </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>
|
||||||
|
/// <param name="description">Human-readable description of the property or type.</param>
|
||||||
|
public ToonDescriptionAttribute(string description)
|
||||||
|
{
|
||||||
|
Description = description ?? throw new ArgumentNullException(nameof(description));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a string representation of this attribute for debugging.
|
||||||
|
/// </summary>
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
var parts = new List<string> { $"Description: {Description}" };
|
||||||
|
if (!string.IsNullOrEmpty(Purpose))
|
||||||
|
parts.Add($"Purpose: {Purpose}");
|
||||||
|
if (!string.IsNullOrEmpty(Constraints))
|
||||||
|
parts.Add($"Constraints: {Constraints}");
|
||||||
|
if (!string.IsNullOrEmpty(Examples))
|
||||||
|
parts.Add($"Examples: {Examples}");
|
||||||
|
return string.Join(", ", parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ using AyCode.Core.Serializers.Jsons;
|
||||||
using AyCode.Interfaces.Entities;
|
using AyCode.Interfaces.Entities;
|
||||||
using Microsoft.AspNetCore.Http.Connections;
|
using Microsoft.AspNetCore.Http.Connections;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace AyCode.Services.SignalRs
|
namespace AyCode.Services.SignalRs
|
||||||
{
|
{
|
||||||
|
|
@ -40,6 +41,18 @@ namespace AyCode.Services.SignalRs
|
||||||
options.CloseTimeout = TimeSpan.FromSeconds(10);
|
options.CloseTimeout = TimeSpan.FromSeconds(10);
|
||||||
options.SkipNegotiation = true;
|
options.SkipNegotiation = true;
|
||||||
})
|
})
|
||||||
|
.ConfigureLogging(logging =>
|
||||||
|
{
|
||||||
|
// alap minimális MS log level
|
||||||
|
logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
|
||||||
|
|
||||||
|
// regisztráljuk az AcLoggerProvider-t úgy, hogy visszaadja a meglévő Logger példányt
|
||||||
|
logging.AddAcLogger(_ => Logger);
|
||||||
|
|
||||||
|
// ha inkább csak AcLogger legyen:
|
||||||
|
// logging.ClearProviders();
|
||||||
|
// logging.AddProvider(new AcLoggerProvider<AcLoggerBase>(category => Logger));
|
||||||
|
})
|
||||||
.WithAutomaticReconnect()
|
.WithAutomaticReconnect()
|
||||||
.WithStatefulReconnect()
|
.WithStatefulReconnect()
|
||||||
.WithKeepAliveInterval(TimeSpan.FromSeconds(60))
|
.WithKeepAliveInterval(TimeSpan.FromSeconds(60))
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue