Generate CultCache document metadata providers
This commit is contained in:
parent
da94389ee2
commit
8606b74d65
11 changed files with 897 additions and 105 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
|
@ -9,37 +10,530 @@ using Microsoft.CodeAnalysis.Text;
|
|||
namespace GameCult.Caching.MessagePack.Generator
|
||||
{
|
||||
[Generator]
|
||||
public class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
||||
public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
||||
{
|
||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
||||
{
|
||||
var documentTypes = context.SyntaxProvider
|
||||
var documentShapes = context.SyntaxProvider
|
||||
.CreateSyntaxProvider(
|
||||
static (node, _) => node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0,
|
||||
static (ctx, _) => ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as INamedTypeSymbol)
|
||||
.Where(static symbol => symbol is not null)!
|
||||
.Select((symbol, _) => symbol!)
|
||||
.Where(static symbol => symbol.GetAttributes().Any(attribute =>
|
||||
attribute.AttributeClass?.ToDisplayString() == "GameCult.Caching.CultDocumentAttribute"));
|
||||
static (ctx, _) => BuildDocumentShape(ctx))
|
||||
.Where(static shape => shape != null)
|
||||
.Select(static (shape, _) => shape!);
|
||||
|
||||
context.RegisterSourceOutput(documentTypes.Collect(), (spc, types) =>
|
||||
var compilationAndShapes = context.CompilationProvider.Combine(documentShapes.Collect());
|
||||
context.RegisterSourceOutput(
|
||||
compilationAndShapes,
|
||||
static (spc, pair) => EmitProvider(spc, pair.Left.AssemblyName ?? "GameCult", pair.Right));
|
||||
}
|
||||
|
||||
private static DocumentShape? BuildDocumentShape(GeneratorSyntaxContext context)
|
||||
{
|
||||
if (context.SemanticModel.GetDeclaredSymbol(context.Node) is not INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("// <auto-generated/>");
|
||||
builder.AppendLine("#nullable enable");
|
||||
builder.AppendLine("using MessagePack.Formatters;");
|
||||
builder.AppendLine("namespace GameCult.Caching.MessagePack");
|
||||
builder.AppendLine("{");
|
||||
builder.AppendLine(" public static partial class GeneratedCultDocumentResolvers");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" public static IMessagePackFormatter<T>? GetFormatter<T>()");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" return null;");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine("}");
|
||||
spc.AddSource("GeneratedCultDocumentResolvers.g.cs", SourceText.From(builder.ToString(), Encoding.UTF8));
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
var documentAttribute = GetAttribute(typeSymbol, "GameCult.Caching.CultDocumentAttribute");
|
||||
if (documentAttribute == null ||
|
||||
documentAttribute.ConstructorArguments.Length < 2 ||
|
||||
documentAttribute.ConstructorArguments[0].Value is not string schemaName ||
|
||||
documentAttribute.ConstructorArguments[1].Value is not string schemaVersion)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var members = DiscoverMembers(typeSymbol);
|
||||
var nameMember = members.FirstOrDefault(member => member.IsName);
|
||||
return new DocumentShape(
|
||||
typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
|
||||
schemaName,
|
||||
schemaVersion,
|
||||
GetAttribute(typeSymbol, "GameCult.Caching.CultGlobalAttribute") != null,
|
||||
nameMember?.Name,
|
||||
nameMember?.AccessorMethodName,
|
||||
members.Where(member => member.IndexAlias != null)
|
||||
.Select(member => new IndexAccessorShape(member.IndexAlias!, member.IndexAccessorMethodName!))
|
||||
.ToImmutableArray(),
|
||||
members.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static ImmutableArray<MemberShape> DiscoverMembers(INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
var candidates = new List<MemberShapeSeed>();
|
||||
foreach (var member in typeSymbol.GetMembers())
|
||||
{
|
||||
if (member is IFieldSymbol field)
|
||||
{
|
||||
if (field.IsStatic || field.DeclaredAccessibility != Accessibility.Public || IsIgnored(field))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add(MemberShapeSeed.From(field, field.Type));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (member is IPropertySymbol property)
|
||||
{
|
||||
if (property.IsStatic ||
|
||||
property.IsIndexer ||
|
||||
property.DeclaredAccessibility != Accessibility.Public ||
|
||||
property.GetMethod == null ||
|
||||
property.SetMethod == null ||
|
||||
IsIgnored(property))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add(MemberShapeSeed.From(property, property.Type));
|
||||
}
|
||||
}
|
||||
|
||||
var explicitMembers = candidates
|
||||
.Where(member => member.ExplicitSlot.HasValue)
|
||||
.OrderBy(member => member.ExplicitSlot!.Value)
|
||||
.ThenBy(member => member.SortKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var implicitMembers = candidates
|
||||
.Where(member => !member.ExplicitSlot.HasValue)
|
||||
.OrderBy(member => member.SortKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var nextSlot = explicitMembers.Length == 0 ? 0 : explicitMembers.Max(member => member.ExplicitSlot!.Value) + 1;
|
||||
var shaped = new List<MemberShape>(candidates.Count);
|
||||
|
||||
foreach (var member in explicitMembers)
|
||||
{
|
||||
shaped.Add(member.WithSlot(member.ExplicitSlot!.Value));
|
||||
}
|
||||
|
||||
foreach (var member in implicitMembers)
|
||||
{
|
||||
shaped.Add(member.WithSlot(nextSlot++));
|
||||
}
|
||||
|
||||
return shaped
|
||||
.OrderBy(member => member.Slot)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool IsIgnored(ISymbol member)
|
||||
{
|
||||
return GetAttribute(member, "MessagePack.IgnoreMemberAttribute") != null;
|
||||
}
|
||||
|
||||
private static AttributeData? GetAttribute(ISymbol symbol, string fullyQualifiedName)
|
||||
{
|
||||
return symbol.GetAttributes().FirstOrDefault(attribute =>
|
||||
attribute.AttributeClass?.ToDisplayString() == fullyQualifiedName);
|
||||
}
|
||||
|
||||
private static int? GetExplicitSlot(ISymbol member)
|
||||
{
|
||||
var keyAttribute = GetAttribute(member, "MessagePack.KeyAttribute");
|
||||
if (keyAttribute == null || keyAttribute.ConstructorArguments.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var argument = keyAttribute.ConstructorArguments[0];
|
||||
return argument.Kind == TypedConstantKind.Primitive && argument.Value is int slot
|
||||
? slot
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void EmitProvider(SourceProductionContext context, string assemblyName, ImmutableArray<DocumentShape> shapes)
|
||||
{
|
||||
var documents = shapes
|
||||
.Where(shape => shape != null)
|
||||
.GroupBy(shape => shape.DocumentTypeName, StringComparer.Ordinal)
|
||||
.Select(group => group.First())
|
||||
.OrderBy(shape => shape.DocumentTypeName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (documents.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var providerName = $"GeneratedCultDocumentMetadataProvider_{SanitizeIdentifier(assemblyName)}";
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("// <auto-generated/>");
|
||||
builder.AppendLine("#nullable enable");
|
||||
builder.AppendLine($"[assembly: global::GameCult.Caching.CultGeneratedDocumentMetadataProviderAttribute(typeof(global::GameCult.Caching.Generated.{providerName}))]");
|
||||
builder.AppendLine("namespace GameCult.Caching.Generated");
|
||||
builder.AppendLine("{");
|
||||
builder.AppendLine($" internal sealed class {providerName} : global::GameCult.Caching.ICultGeneratedDocumentMetadataProvider");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" private static readonly global::GameCult.Caching.CultGeneratedDocumentDefinition[] s_documents =");
|
||||
builder.AppendLine(" {");
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentDefinition(");
|
||||
builder.AppendLine($" typeof({document.DocumentTypeName}),");
|
||||
builder.AppendLine($" \"{Escape(document.SchemaName)}\",");
|
||||
builder.AppendLine($" \"{Escape(document.SchemaVersion)}\",");
|
||||
builder.AppendLine($" {(document.IsGlobal ? "true" : "false")},");
|
||||
builder.AppendLine(document.NameMember == null
|
||||
? " null,"
|
||||
: $" \"{Escape(document.NameMember)}\",");
|
||||
builder.AppendLine(document.NameAccessorMethodName == null
|
||||
? " null,"
|
||||
: $" {document.NameAccessorMethodName},");
|
||||
builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentIndexAccessor[]");
|
||||
builder.AppendLine(" {");
|
||||
foreach (var indexAccessor in document.IndexAccessors)
|
||||
{
|
||||
builder.AppendLine($" new global::GameCult.Caching.CultGeneratedDocumentIndexAccessor(\"{Escape(indexAccessor.Alias)}\", {indexAccessor.AccessorMethodName}),");
|
||||
}
|
||||
builder.AppendLine(" },");
|
||||
builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentMemberDefinition[]");
|
||||
builder.AppendLine(" {");
|
||||
foreach (var member in document.Members)
|
||||
{
|
||||
builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentMemberDefinition(");
|
||||
builder.AppendLine($" \"{Escape(member.Name)}\",");
|
||||
builder.AppendLine($" {member.Slot},");
|
||||
builder.AppendLine($" \"{Escape(member.TypeName)}\",");
|
||||
builder.AppendLine($" {(member.IsReference ? "true" : "false")},");
|
||||
builder.AppendLine($" {(member.IsMany ? "true" : "false")},");
|
||||
builder.AppendLine(member.TargetSchemaName == null
|
||||
? " null,"
|
||||
: $" \"{Escape(member.TargetSchemaName)}\",");
|
||||
builder.AppendLine($" {(member.IsName ? "true" : "false")},");
|
||||
builder.AppendLine(member.IndexAlias == null
|
||||
? " null),"
|
||||
: $" \"{Escape(member.IndexAlias)}\"),");
|
||||
}
|
||||
builder.AppendLine(" }),");
|
||||
}
|
||||
|
||||
builder.AppendLine(" };");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(" public global::System.Collections.Generic.IEnumerable<global::GameCult.Caching.CultGeneratedDocumentDefinition> GetDocumentDefinitions()");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(" return s_documents;");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
foreach (var member in document.Members.Where(member => member.AccessorMethodName != null))
|
||||
{
|
||||
builder.AppendLine($" private static string? {member.AccessorMethodName}(object document)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(member.CanBeNull
|
||||
? $" return (({document.DocumentTypeName})document).{member.Name}?.ToString();"
|
||||
: $" return (({document.DocumentTypeName})document).{member.Name}.ToString();");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
foreach (var member in document.Members.Where(member => member.IndexAccessorMethodName != null))
|
||||
{
|
||||
builder.AppendLine($" private static string {member.IndexAccessorMethodName}(object document)");
|
||||
builder.AppendLine(" {");
|
||||
builder.AppendLine(member.CanBeNull
|
||||
? $" return (({document.DocumentTypeName})document).{member.Name}?.ToString() ?? string.Empty;"
|
||||
: $" return (({document.DocumentTypeName})document).{member.Name}.ToString();");
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
builder.AppendLine(" }");
|
||||
builder.AppendLine("}");
|
||||
context.AddSource($"{providerName}.g.cs", SourceText.From(builder.ToString(), Encoding.UTF8));
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\n", "\\n")
|
||||
.Replace("\t", "\\t");
|
||||
}
|
||||
|
||||
private static string SanitizeIdentifier(string value)
|
||||
{
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var character in value)
|
||||
{
|
||||
builder.Append(char.IsLetterOrDigit(character) ? character : '_');
|
||||
}
|
||||
|
||||
if (builder.Length == 0 || !char.IsLetter(builder[0]) && builder[0] != '_')
|
||||
{
|
||||
builder.Insert(0, '_');
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string GetSchemaTypeName(ITypeSymbol typeSymbol)
|
||||
{
|
||||
if (typeSymbol is IArrayTypeSymbol arrayType)
|
||||
{
|
||||
return GetSchemaTypeName(arrayType.ElementType) + "[]";
|
||||
}
|
||||
|
||||
if (typeSymbol is INamedTypeSymbol namedType && namedType.IsGenericType)
|
||||
{
|
||||
var baseName = GetNamedTypeBaseName(namedType.ConstructedFrom);
|
||||
var arguments = string.Join(", ", namedType.TypeArguments.Select(GetSchemaTypeName));
|
||||
return $"{baseName}<{arguments}>";
|
||||
}
|
||||
|
||||
return typeSymbol.SpecialType switch
|
||||
{
|
||||
SpecialType.System_Boolean => "System.Boolean",
|
||||
SpecialType.System_Byte => "System.Byte",
|
||||
SpecialType.System_Char => "System.Char",
|
||||
SpecialType.System_Decimal => "System.Decimal",
|
||||
SpecialType.System_Double => "System.Double",
|
||||
SpecialType.System_Int16 => "System.Int16",
|
||||
SpecialType.System_Int32 => "System.Int32",
|
||||
SpecialType.System_Int64 => "System.Int64",
|
||||
SpecialType.System_Object => "System.Object",
|
||||
SpecialType.System_SByte => "System.SByte",
|
||||
SpecialType.System_Single => "System.Single",
|
||||
SpecialType.System_String => "System.String",
|
||||
SpecialType.System_UInt16 => "System.UInt16",
|
||||
SpecialType.System_UInt32 => "System.UInt32",
|
||||
SpecialType.System_UInt64 => "System.UInt64",
|
||||
_ => GetNamedTypeBaseName((INamedTypeSymbol)typeSymbol)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetNamedTypeBaseName(INamedTypeSymbol typeSymbol)
|
||||
{
|
||||
var parts = new Stack<string>();
|
||||
var current = typeSymbol;
|
||||
while (current != null)
|
||||
{
|
||||
parts.Push(current.Name);
|
||||
current = current.ContainingType;
|
||||
}
|
||||
|
||||
var namespaceName = typeSymbol.ContainingNamespace?.ToDisplayString();
|
||||
return string.IsNullOrWhiteSpace(namespaceName)
|
||||
? string.Join(".", parts)
|
||||
: namespaceName + "." + string.Join(".", parts);
|
||||
}
|
||||
|
||||
private static bool CanBeNull(ITypeSymbol typeSymbol)
|
||||
{
|
||||
if (typeSymbol.NullableAnnotation == NullableAnnotation.Annotated)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeSymbol.IsReferenceType)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeSymbol is INamedTypeSymbol namedType &&
|
||||
namedType.IsGenericType &&
|
||||
namedType.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T;
|
||||
}
|
||||
|
||||
private sealed class MemberShapeSeed
|
||||
{
|
||||
private MemberShapeSeed(
|
||||
string name,
|
||||
string typeName,
|
||||
int? explicitSlot,
|
||||
string sortKey,
|
||||
bool isName,
|
||||
string? indexAlias,
|
||||
bool isReference,
|
||||
bool isMany,
|
||||
bool canBeNull,
|
||||
string? targetSchemaName)
|
||||
{
|
||||
Name = name;
|
||||
TypeName = typeName;
|
||||
ExplicitSlot = explicitSlot;
|
||||
SortKey = sortKey;
|
||||
IsName = isName;
|
||||
IndexAlias = indexAlias;
|
||||
IsReference = isReference;
|
||||
IsMany = isMany;
|
||||
CanBeNull = canBeNull;
|
||||
TargetSchemaName = targetSchemaName;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string TypeName { get; }
|
||||
public int? ExplicitSlot { get; }
|
||||
public string SortKey { get; }
|
||||
public bool IsName { get; }
|
||||
public string? IndexAlias { get; }
|
||||
public bool IsReference { get; }
|
||||
public bool IsMany { get; }
|
||||
public bool CanBeNull { get; }
|
||||
public string? TargetSchemaName { get; }
|
||||
|
||||
public MemberShape WithSlot(int slot)
|
||||
{
|
||||
var accessorStem = SanitizeIdentifier(SortKey);
|
||||
return new MemberShape(
|
||||
Name,
|
||||
slot,
|
||||
TypeName,
|
||||
IsReference,
|
||||
IsMany,
|
||||
TargetSchemaName,
|
||||
IsName,
|
||||
IndexAlias,
|
||||
CanBeNull,
|
||||
IsName ? $"Access_{accessorStem}_Name" : null,
|
||||
IndexAlias != null ? $"Access_{accessorStem}_Index" : null);
|
||||
}
|
||||
|
||||
public static MemberShapeSeed From(ISymbol member, ITypeSymbol memberType)
|
||||
{
|
||||
var referenceAttribute = GetAttribute(member, "GameCult.Caching.CultReferenceAttribute");
|
||||
var explicitTarget = referenceAttribute?.ConstructorArguments.Length > 0
|
||||
? referenceAttribute.ConstructorArguments[0].Value as INamedTypeSymbol
|
||||
: null;
|
||||
var many = referenceAttribute?.ConstructorArguments.Length > 1 &&
|
||||
referenceAttribute.ConstructorArguments[1].Value is bool manyValue &&
|
||||
manyValue;
|
||||
var targetType = ResolveReferenceTarget(memberType, explicitTarget);
|
||||
var targetSchemaName = targetType == null
|
||||
? null
|
||||
: GetAttribute(targetType, "GameCult.Caching.CultDocumentAttribute")?.ConstructorArguments[0].Value as string;
|
||||
var indexAttribute = GetAttribute(member, "GameCult.Caching.CultIndexAttribute");
|
||||
var indexAlias = indexAttribute == null
|
||||
? null
|
||||
: indexAttribute.ConstructorArguments.Length == 0 || indexAttribute.ConstructorArguments[0].Value is not string alias || string.IsNullOrWhiteSpace(alias)
|
||||
? member.Name
|
||||
: alias;
|
||||
|
||||
return new MemberShapeSeed(
|
||||
member.Name,
|
||||
GetSchemaTypeName(memberType),
|
||||
GetExplicitSlot(member),
|
||||
BuildAccessorStem(member),
|
||||
GetAttribute(member, "GameCult.Caching.CultNameAttribute") != null,
|
||||
indexAlias,
|
||||
targetType != null || referenceAttribute != null,
|
||||
many,
|
||||
CanBeNull(memberType),
|
||||
targetSchemaName);
|
||||
}
|
||||
|
||||
private static INamedTypeSymbol? ResolveReferenceTarget(ITypeSymbol memberType, INamedTypeSymbol? explicitTarget)
|
||||
{
|
||||
if (explicitTarget != null)
|
||||
{
|
||||
return explicitTarget;
|
||||
}
|
||||
|
||||
return memberType is INamedTypeSymbol namedType &&
|
||||
namedType.IsGenericType &&
|
||||
namedType.Name == "CultRecordRef" &&
|
||||
namedType.ContainingNamespace.ToDisplayString() == "GameCult.Caching"
|
||||
? namedType.TypeArguments[0] as INamedTypeSymbol
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string BuildAccessorStem(ISymbol member)
|
||||
{
|
||||
return member.ContainingType == null
|
||||
? member.Name
|
||||
: member.ContainingType.Name + "_" + member.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DocumentShape
|
||||
{
|
||||
public DocumentShape(
|
||||
string documentTypeName,
|
||||
string schemaName,
|
||||
string schemaVersion,
|
||||
bool isGlobal,
|
||||
string? nameMember,
|
||||
string? nameAccessorMethodName,
|
||||
ImmutableArray<IndexAccessorShape> indexAccessors,
|
||||
ImmutableArray<MemberShape> members)
|
||||
{
|
||||
DocumentTypeName = documentTypeName;
|
||||
SchemaName = schemaName;
|
||||
SchemaVersion = schemaVersion;
|
||||
IsGlobal = isGlobal;
|
||||
NameMember = nameMember;
|
||||
NameAccessorMethodName = nameAccessorMethodName;
|
||||
IndexAccessors = indexAccessors;
|
||||
Members = members;
|
||||
}
|
||||
|
||||
public string DocumentTypeName { get; }
|
||||
public string SchemaName { get; }
|
||||
public string SchemaVersion { get; }
|
||||
public bool IsGlobal { get; }
|
||||
public string? NameMember { get; }
|
||||
public string? NameAccessorMethodName { get; }
|
||||
public ImmutableArray<IndexAccessorShape> IndexAccessors { get; }
|
||||
public ImmutableArray<MemberShape> Members { get; }
|
||||
}
|
||||
|
||||
private sealed class IndexAccessorShape
|
||||
{
|
||||
public IndexAccessorShape(string alias, string accessorMethodName)
|
||||
{
|
||||
Alias = alias;
|
||||
AccessorMethodName = accessorMethodName;
|
||||
}
|
||||
|
||||
public string Alias { get; }
|
||||
public string AccessorMethodName { get; }
|
||||
}
|
||||
|
||||
private sealed class MemberShape
|
||||
{
|
||||
public MemberShape(
|
||||
string name,
|
||||
int slot,
|
||||
string typeName,
|
||||
bool isReference,
|
||||
bool isMany,
|
||||
string? targetSchemaName,
|
||||
bool isName,
|
||||
string? indexAlias,
|
||||
bool canBeNull,
|
||||
string? accessorMethodName,
|
||||
string? indexAccessorMethodName)
|
||||
{
|
||||
Name = name;
|
||||
Slot = slot;
|
||||
TypeName = typeName;
|
||||
IsReference = isReference;
|
||||
IsMany = isMany;
|
||||
TargetSchemaName = targetSchemaName;
|
||||
IsName = isName;
|
||||
IndexAlias = indexAlias;
|
||||
CanBeNull = canBeNull;
|
||||
AccessorMethodName = accessorMethodName;
|
||||
IndexAccessorMethodName = indexAccessorMethodName;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int Slot { get; }
|
||||
public string TypeName { get; }
|
||||
public bool IsReference { get; }
|
||||
public bool IsMany { get; }
|
||||
public string? TargetSchemaName { get; }
|
||||
public bool IsName { get; }
|
||||
public string? IndexAlias { get; }
|
||||
public bool CanBeNull { get; }
|
||||
public string? AccessorMethodName { get; }
|
||||
public string? IndexAccessorMethodName { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -6,20 +6,6 @@ using MessagePack.Resolvers;
|
|||
|
||||
namespace GameCult.Caching.MessagePack;
|
||||
|
||||
public sealed class CultRecordRefFormatter<T> : IMessagePackFormatter<CultRecordRef<T>>
|
||||
{
|
||||
public void Serialize(ref MessagePackWriter writer, CultRecordRef<T> value, MessagePackSerializerOptions options)
|
||||
{
|
||||
options.Resolver.GetFormatterWithVerify<string>().Serialize(ref writer, value.Key.Value, options);
|
||||
}
|
||||
|
||||
public CultRecordRef<T> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
var key = options.Resolver.GetFormatterWithVerify<string>().Deserialize(ref reader, options) ?? string.Empty;
|
||||
return new CultRecordRef<T>(new CultRecordKey(key));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CultDocumentResolver : IFormatterResolver
|
||||
{
|
||||
public static readonly CultDocumentResolver Instance = new();
|
||||
|
|
@ -27,16 +13,10 @@ public sealed class CultDocumentResolver : IFormatterResolver
|
|||
|
||||
public IMessagePackFormatter<T>? GetFormatter<T>()
|
||||
{
|
||||
var generated = GeneratedCultDocumentResolvers.GetFormatter<T>();
|
||||
if (generated != null)
|
||||
{
|
||||
return generated;
|
||||
}
|
||||
|
||||
var type = typeof(T);
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
|
||||
{
|
||||
var formatterType = typeof(CultRecordRefFormatter<>).MakeGenericType(type.GetGenericArguments()[0]);
|
||||
var formatterType = typeof(global::GameCult.Caching.CultRecordRefFormatter<>).MakeGenericType(type.GetGenericArguments()[0]);
|
||||
return (IMessagePackFormatter<T>)Activator.CreateInstance(formatterType)!;
|
||||
}
|
||||
|
||||
|
|
@ -74,11 +54,6 @@ public static class CultDocumentMessagePackSerialization
|
|||
}
|
||||
}
|
||||
|
||||
public static class GeneratedCultDocumentResolvers
|
||||
{
|
||||
public static IMessagePackFormatter<T>? GetFormatter<T>() => null;
|
||||
}
|
||||
|
||||
public class SingleFileMessagePackBackingStore : SingleFileBackingStore
|
||||
{
|
||||
public SingleFileMessagePackBackingStore(string filePath) : base(filePath)
|
||||
|
|
|
|||
18
src/GameCult.Caching.MessagePack/CultRecordRefFormatter.cs
Normal file
18
src/GameCult.Caching.MessagePack/CultRecordRefFormatter.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using MessagePack;
|
||||
using MessagePack.Formatters;
|
||||
|
||||
namespace GameCult.Caching;
|
||||
|
||||
public sealed class CultRecordRefFormatter<T> : IMessagePackFormatter<CultRecordRef<T>>
|
||||
{
|
||||
public void Serialize(ref MessagePackWriter writer, CultRecordRef<T> value, MessagePackSerializerOptions options)
|
||||
{
|
||||
options.Resolver.GetFormatterWithVerify<string>().Serialize(ref writer, value.Key.Value, options);
|
||||
}
|
||||
|
||||
public CultRecordRef<T> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
var key = options.Resolver.GetFormatterWithVerify<string>().Deserialize(ref reader, options) ?? string.Empty;
|
||||
return new CultRecordRef<T>(new CultRecordKey(key));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,11 @@ attribute-first `GameCult.Caching` stack.
|
|||
- `CultDocumentResolver`
|
||||
- `CultRecordRefFormatter<T>`
|
||||
|
||||
When a project declares `[CultDocument]` models, wire in the
|
||||
`GameCult.Caching.MessagePack.Generator` analyzer there as well. It emits
|
||||
assembly-local metadata providers for the cache registry; MessagePack itself
|
||||
still handles payload serialization through the normal resolver path.
|
||||
|
||||
## What It Does
|
||||
|
||||
- stores CultCache snapshots and records in MessagePack
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ namespace GameCult.Caching
|
|||
public bool IsReference { get; set; }
|
||||
public bool IsMany { get; set; }
|
||||
public string? TargetSchemaName { get; set; }
|
||||
public bool IsName { get; set; }
|
||||
public string? IndexAlias { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CultStoredDocument
|
||||
|
|
@ -156,12 +158,17 @@ namespace GameCult.Caching
|
|||
_bySchemaId.Clear();
|
||||
_bySchemaName.Clear();
|
||||
|
||||
foreach (var type in ReflectionExtensions.GetAttributedDocumentTypes())
|
||||
var generatedTypes = new HashSet<Type>();
|
||||
foreach (var definition in CultGeneratedDocumentMetadataLoader.LoadDefinitions())
|
||||
{
|
||||
var descriptor = BuildDescriptor(type);
|
||||
_byType[type] = descriptor;
|
||||
_bySchemaId[descriptor.SchemaId] = descriptor;
|
||||
_bySchemaName[descriptor.SchemaName] = descriptor;
|
||||
var descriptor = BuildDescriptor(definition);
|
||||
RegisterDescriptor(descriptor);
|
||||
generatedTypes.Add(descriptor.DocumentType);
|
||||
}
|
||||
|
||||
foreach (var type in ReflectionExtensions.GetAttributedDocumentTypes().Where(type => !generatedTypes.Contains(type)))
|
||||
{
|
||||
RegisterDescriptor(BuildDescriptor(type));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +179,13 @@ namespace GameCult.Caching
|
|||
return descriptor;
|
||||
}
|
||||
|
||||
descriptor = TryBuildGeneratedDescriptor(type);
|
||||
if (descriptor != null)
|
||||
{
|
||||
RegisterDescriptor(descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
var attribute = type.GetCustomAttribute<CultDocumentAttribute>();
|
||||
if (attribute == null)
|
||||
{
|
||||
|
|
@ -180,9 +194,7 @@ namespace GameCult.Caching
|
|||
}
|
||||
|
||||
descriptor = BuildDescriptor(type);
|
||||
_byType[type] = descriptor;
|
||||
_bySchemaId[descriptor.SchemaId] = descriptor;
|
||||
_bySchemaName[descriptor.SchemaName] = descriptor;
|
||||
RegisterDescriptor(descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +235,22 @@ namespace GameCult.Caching
|
|||
$"No local CultCache schema matches persisted schema '{persisted.SchemaName}' ({schemaId}).");
|
||||
}
|
||||
|
||||
private void RegisterDescriptor(CultDocumentDescriptor descriptor)
|
||||
{
|
||||
_byType[descriptor.DocumentType] = descriptor;
|
||||
_bySchemaId[descriptor.SchemaId] = descriptor;
|
||||
_bySchemaName[descriptor.SchemaName] = descriptor;
|
||||
}
|
||||
|
||||
private static CultDocumentDescriptor? TryBuildGeneratedDescriptor(Type type)
|
||||
{
|
||||
var definition = CultGeneratedDocumentMetadataLoader.LoadDefinitions(type.Assembly)
|
||||
.FirstOrDefault(candidate => candidate.DocumentType == type);
|
||||
return definition == null
|
||||
? null
|
||||
: BuildDescriptor(definition);
|
||||
}
|
||||
|
||||
private static CultDocumentDescriptor BuildDescriptor(Type type)
|
||||
{
|
||||
var attribute = type.GetCustomAttribute<CultDocumentAttribute>()
|
||||
|
|
@ -236,10 +264,22 @@ namespace GameCult.Caching
|
|||
member => member.IndexAlias!,
|
||||
member => member.Getter,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var schemaJson = BuildCanonicalSchemaJson(attribute, members);
|
||||
var descriptorMembers = members
|
||||
.Select(member => new CultDocumentMemberDescriptor
|
||||
{
|
||||
MemberName = member.Member.Name,
|
||||
Slot = member.Slot,
|
||||
TypeName = CultSchemaTypeNames.FromType(member.MemberType),
|
||||
IsReference = member.IsReference,
|
||||
IsMany = member.IsMany,
|
||||
TargetSchemaName = member.TargetSchemaName,
|
||||
IsName = member.IsName,
|
||||
IndexAlias = member.IndexAlias
|
||||
})
|
||||
.ToArray();
|
||||
var schemaJson = BuildCanonicalSchemaJson(attribute.SchemaName, attribute.SchemaVersion, descriptorMembers);
|
||||
var contentHash = Sha256(schemaJson);
|
||||
var semanticFingerprint = BuildSemanticFingerprint(attribute, members);
|
||||
var semanticFingerprint = BuildSemanticFingerprint(attribute.SchemaName, attribute.SchemaVersion, descriptorMembers);
|
||||
var schemaId = Sha256(semanticFingerprint);
|
||||
|
||||
return new CultDocumentDescriptor(
|
||||
|
|
@ -253,33 +293,61 @@ namespace GameCult.Caching
|
|||
nameMember?.Member.Name,
|
||||
nameMember?.GetterNullable,
|
||||
indexAccessors,
|
||||
members.Select(member => new CultDocumentMemberDescriptor
|
||||
descriptorMembers);
|
||||
}
|
||||
|
||||
private static CultDocumentDescriptor BuildDescriptor(CultGeneratedDocumentDefinition definition)
|
||||
{
|
||||
var descriptorMembers = definition.Members
|
||||
.OrderBy(member => member.Slot)
|
||||
.Select(member => new CultDocumentMemberDescriptor
|
||||
{
|
||||
MemberName = member.Member.Name,
|
||||
MemberName = member.MemberName,
|
||||
Slot = member.Slot,
|
||||
TypeName = member.MemberType.FullName ?? member.MemberType.Name,
|
||||
TypeName = member.TypeName,
|
||||
IsReference = member.IsReference,
|
||||
IsMany = member.IsMany,
|
||||
TargetSchemaName = member.TargetSchemaName
|
||||
}).ToArray());
|
||||
TargetSchemaName = member.TargetSchemaName,
|
||||
IsName = member.IsName,
|
||||
IndexAlias = member.IndexAlias
|
||||
})
|
||||
.ToArray();
|
||||
var schemaJson = BuildCanonicalSchemaJson(definition.SchemaName, definition.SchemaVersion, descriptorMembers);
|
||||
var contentHash = Sha256(schemaJson);
|
||||
var semanticFingerprint = BuildSemanticFingerprint(definition.SchemaName, definition.SchemaVersion, descriptorMembers);
|
||||
var schemaId = Sha256(semanticFingerprint);
|
||||
|
||||
return new CultDocumentDescriptor(
|
||||
definition.DocumentType,
|
||||
definition.SchemaName,
|
||||
definition.SchemaVersion,
|
||||
schemaId,
|
||||
contentHash,
|
||||
schemaJson,
|
||||
definition.IsGlobal,
|
||||
definition.NameMember,
|
||||
definition.NameAccessor,
|
||||
definition.IndexAccessors.ToDictionary(accessor => accessor.Alias, accessor => accessor.Accessor, StringComparer.Ordinal),
|
||||
descriptorMembers);
|
||||
}
|
||||
|
||||
private static string BuildSemanticFingerprint(
|
||||
CultDocumentAttribute attribute,
|
||||
IReadOnlyList<PersistedMember> members)
|
||||
string schemaName,
|
||||
string schemaVersion,
|
||||
IReadOnlyList<CultDocumentMemberDescriptor> members)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(attribute.SchemaName)
|
||||
builder.Append(schemaName)
|
||||
.Append('|')
|
||||
.Append(attribute.SchemaVersion);
|
||||
.Append(schemaVersion);
|
||||
foreach (var member in members.OrderBy(member => member.Slot))
|
||||
{
|
||||
builder.Append('|')
|
||||
.Append(member.Slot)
|
||||
.Append(':')
|
||||
.Append(member.Member.Name)
|
||||
.Append(member.MemberName)
|
||||
.Append(':')
|
||||
.Append(member.MemberType.FullName ?? member.MemberType.Name)
|
||||
.Append(member.TypeName)
|
||||
.Append(':')
|
||||
.Append(member.IsReference ? "ref" : "value")
|
||||
.Append(':')
|
||||
|
|
@ -292,34 +360,15 @@ namespace GameCult.Caching
|
|||
}
|
||||
|
||||
private static string BuildCanonicalSchemaJson(
|
||||
CultDocumentAttribute attribute,
|
||||
IReadOnlyList<PersistedMember> members)
|
||||
string schemaName,
|
||||
string schemaVersion,
|
||||
IReadOnlyList<CultDocumentMemberDescriptor> members)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
schemaName = attribute.SchemaName,
|
||||
schemaVersion = attribute.SchemaVersion,
|
||||
members = members
|
||||
.OrderBy(member => member.Slot)
|
||||
.Select(member => new
|
||||
{
|
||||
slot = member.Slot,
|
||||
name = member.Member.Name,
|
||||
type = member.MemberType.FullName ?? member.MemberType.Name,
|
||||
isReference = member.IsReference,
|
||||
many = member.IsMany,
|
||||
targetSchemaName = member.TargetSchemaName,
|
||||
indexAlias = member.IndexAlias,
|
||||
isName = member.IsName
|
||||
})
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("{\"schemaName\":\"")
|
||||
.Append(attribute.SchemaName)
|
||||
.Append(CultSchemaTypeNames.EscapeForLiteral(schemaName))
|
||||
.Append("\",\"schemaVersion\":\"")
|
||||
.Append(attribute.SchemaVersion)
|
||||
.Append(CultSchemaTypeNames.EscapeForLiteral(schemaVersion))
|
||||
.Append("\",\"members\":[");
|
||||
for (var index = 0; index < members.Count; index++)
|
||||
{
|
||||
|
|
@ -332,9 +381,9 @@ namespace GameCult.Caching
|
|||
builder.Append("{\"slot\":")
|
||||
.Append(member.Slot)
|
||||
.Append(",\"name\":\"")
|
||||
.Append(member.Member.Name)
|
||||
.Append(CultSchemaTypeNames.EscapeForLiteral(member.MemberName))
|
||||
.Append("\",\"type\":\"")
|
||||
.Append(member.MemberType.FullName ?? member.MemberType.Name)
|
||||
.Append(CultSchemaTypeNames.EscapeForLiteral(member.TypeName))
|
||||
.Append("\",\"isReference\":")
|
||||
.Append(member.IsReference ? "true" : "false")
|
||||
.Append(",\"many\":")
|
||||
|
|
@ -346,7 +395,7 @@ namespace GameCult.Caching
|
|||
}
|
||||
else
|
||||
{
|
||||
builder.Append('"').Append(member.TargetSchemaName).Append('"');
|
||||
builder.Append('"').Append(CultSchemaTypeNames.EscapeForLiteral(member.TargetSchemaName)).Append('"');
|
||||
}
|
||||
|
||||
builder.Append(",\"indexAlias\":");
|
||||
|
|
@ -356,7 +405,7 @@ namespace GameCult.Caching
|
|||
}
|
||||
else
|
||||
{
|
||||
builder.Append('"').Append(member.IndexAlias).Append('"');
|
||||
builder.Append('"').Append(CultSchemaTypeNames.EscapeForLiteral(member.IndexAlias)).Append('"');
|
||||
}
|
||||
|
||||
builder.Append(",\"isName\":")
|
||||
|
|
@ -485,7 +534,7 @@ namespace GameCult.Caching
|
|||
Slot = explicitSlot ?? -1,
|
||||
IsName = member.GetCustomAttribute<CultNameAttribute>() != null,
|
||||
IndexAlias = ResolveIndexAlias(member),
|
||||
IsReference = referenceAttribute != null,
|
||||
IsReference = targetType != null || referenceAttribute != null,
|
||||
IsMany = referenceAttribute?.Many ?? false,
|
||||
TargetSchemaName = targetSchemaName,
|
||||
Getter = document => getValue(document)?.ToString() ?? string.Empty,
|
||||
|
|
|
|||
192
src/GameCult.Caching/CultGeneratedDocumentMetadata.cs
Normal file
192
src/GameCult.Caching/CultGeneratedDocumentMetadata.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace GameCult.Caching
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
|
||||
public sealed class CultGeneratedDocumentMetadataProviderAttribute : Attribute
|
||||
{
|
||||
public CultGeneratedDocumentMetadataProviderAttribute(Type providerType)
|
||||
{
|
||||
ProviderType = providerType ?? throw new ArgumentNullException(nameof(providerType));
|
||||
}
|
||||
|
||||
public Type ProviderType { get; }
|
||||
}
|
||||
|
||||
public interface ICultGeneratedDocumentMetadataProvider
|
||||
{
|
||||
IEnumerable<CultGeneratedDocumentDefinition> GetDocumentDefinitions();
|
||||
}
|
||||
|
||||
public sealed class CultGeneratedDocumentDefinition
|
||||
{
|
||||
public CultGeneratedDocumentDefinition(
|
||||
Type documentType,
|
||||
string schemaName,
|
||||
string schemaVersion,
|
||||
bool isGlobal,
|
||||
string? nameMember,
|
||||
Func<object, string?>? nameAccessor,
|
||||
IReadOnlyList<CultGeneratedDocumentIndexAccessor> indexAccessors,
|
||||
IReadOnlyList<CultGeneratedDocumentMemberDefinition> members)
|
||||
{
|
||||
DocumentType = documentType ?? throw new ArgumentNullException(nameof(documentType));
|
||||
SchemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? throw new ArgumentException("Schema name must be non-empty.", nameof(schemaName))
|
||||
: schemaName;
|
||||
SchemaVersion = string.IsNullOrWhiteSpace(schemaVersion)
|
||||
? throw new ArgumentException("Schema version must be non-empty.", nameof(schemaVersion))
|
||||
: schemaVersion;
|
||||
IsGlobal = isGlobal;
|
||||
NameMember = nameMember;
|
||||
NameAccessor = nameAccessor;
|
||||
IndexAccessors = indexAccessors ?? Array.Empty<CultGeneratedDocumentIndexAccessor>();
|
||||
Members = members ?? Array.Empty<CultGeneratedDocumentMemberDefinition>();
|
||||
}
|
||||
|
||||
public Type DocumentType { get; }
|
||||
public string SchemaName { get; }
|
||||
public string SchemaVersion { get; }
|
||||
public bool IsGlobal { get; }
|
||||
public string? NameMember { get; }
|
||||
public Func<object, string?>? NameAccessor { get; }
|
||||
public IReadOnlyList<CultGeneratedDocumentIndexAccessor> IndexAccessors { get; }
|
||||
public IReadOnlyList<CultGeneratedDocumentMemberDefinition> Members { get; }
|
||||
}
|
||||
|
||||
public sealed class CultGeneratedDocumentIndexAccessor
|
||||
{
|
||||
public CultGeneratedDocumentIndexAccessor(string alias, Func<object, string> accessor)
|
||||
{
|
||||
Alias = string.IsNullOrWhiteSpace(alias)
|
||||
? throw new ArgumentException("Alias must be non-empty.", nameof(alias))
|
||||
: alias;
|
||||
Accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
|
||||
}
|
||||
|
||||
public string Alias { get; }
|
||||
public Func<object, string> Accessor { get; }
|
||||
}
|
||||
|
||||
public sealed class CultGeneratedDocumentMemberDefinition
|
||||
{
|
||||
public CultGeneratedDocumentMemberDefinition(
|
||||
string memberName,
|
||||
int slot,
|
||||
string typeName,
|
||||
bool isReference,
|
||||
bool isMany,
|
||||
string? targetSchemaName,
|
||||
bool isName,
|
||||
string? indexAlias)
|
||||
{
|
||||
MemberName = string.IsNullOrWhiteSpace(memberName)
|
||||
? throw new ArgumentException("Member name must be non-empty.", nameof(memberName))
|
||||
: memberName;
|
||||
Slot = slot;
|
||||
TypeName = string.IsNullOrWhiteSpace(typeName)
|
||||
? throw new ArgumentException("Type name must be non-empty.", nameof(typeName))
|
||||
: typeName;
|
||||
IsReference = isReference;
|
||||
IsMany = isMany;
|
||||
TargetSchemaName = targetSchemaName;
|
||||
IsName = isName;
|
||||
IndexAlias = indexAlias;
|
||||
}
|
||||
|
||||
public string MemberName { get; }
|
||||
public int Slot { get; }
|
||||
public string TypeName { get; }
|
||||
public bool IsReference { get; }
|
||||
public bool IsMany { get; }
|
||||
public string? TargetSchemaName { get; }
|
||||
public bool IsName { get; }
|
||||
public string? IndexAlias { get; }
|
||||
}
|
||||
|
||||
internal static class CultGeneratedDocumentMetadataLoader
|
||||
{
|
||||
public static IEnumerable<CultGeneratedDocumentDefinition> LoadDefinitions()
|
||||
{
|
||||
return AppDomain.CurrentDomain.GetAssemblies()
|
||||
.Where(assembly => !assembly.IsDynamic)
|
||||
.SelectMany(LoadDefinitions);
|
||||
}
|
||||
|
||||
public static IEnumerable<CultGeneratedDocumentDefinition> LoadDefinitions(Assembly assembly)
|
||||
{
|
||||
foreach (var attribute in assembly.GetCustomAttributes<CultGeneratedDocumentMetadataProviderAttribute>())
|
||||
{
|
||||
if (!typeof(ICultGeneratedDocumentMetadataProvider).IsAssignableFrom(attribute.ProviderType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Activator.CreateInstance(attribute.ProviderType) is not ICultGeneratedDocumentMetadataProvider provider)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var definition in provider.GetDocumentDefinitions())
|
||||
{
|
||||
yield return definition;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class CultSchemaTypeNames
|
||||
{
|
||||
public static string FromType(Type type)
|
||||
{
|
||||
if (type.IsArray)
|
||||
{
|
||||
return FromType(type.GetElementType()!) + "[]";
|
||||
}
|
||||
|
||||
if (type.IsGenericType)
|
||||
{
|
||||
var definition = type.GetGenericTypeDefinition();
|
||||
var name = definition.FullName ?? definition.Name;
|
||||
var tickIndex = name.IndexOf('`');
|
||||
if (tickIndex >= 0)
|
||||
{
|
||||
name = name[..tickIndex];
|
||||
}
|
||||
|
||||
var arguments = string.Join(", ", type.GetGenericArguments().Select(FromType));
|
||||
return $"{name}<{arguments}>";
|
||||
}
|
||||
|
||||
return type.FullName ?? type.Name;
|
||||
}
|
||||
|
||||
public static string EscapeForLiteral(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var character in value)
|
||||
{
|
||||
builder.Append(character switch
|
||||
{
|
||||
'\\' => "\\\\",
|
||||
'"' => "\\\"",
|
||||
'\r' => "\\r",
|
||||
'\n' => "\\n",
|
||||
'\t' => "\\t",
|
||||
_ => character.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,12 @@ Available cache attributes:
|
|||
- `CultGlobal`
|
||||
- `CultReference(targetType?, many: false)`
|
||||
|
||||
If you want compile-time document metadata instead of runtime assembly
|
||||
spelunking, add the `GameCult.Caching.MessagePack.Generator` analyzer to the
|
||||
project that declares your `[CultDocument]` types. It emits a metadata provider
|
||||
the cache registry can load directly, while reflection stays behind as fallback
|
||||
for projects that have not taken the hint yet.
|
||||
|
||||
## Main Concepts
|
||||
|
||||
### Record keys and handles
|
||||
|
|
|
|||
|
|
@ -148,8 +148,44 @@ namespace GameCult.Caching.Tests
|
|||
Throws.TypeOf<MessagePackSerializationException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GeneratedMetadataProvider_Emits_AttributedDocuments_And_References()
|
||||
{
|
||||
var providers = typeof(NamedTestEntry).Assembly
|
||||
.GetCustomAttributes(typeof(CultGeneratedDocumentMetadataProviderAttribute), false)
|
||||
.Cast<CultGeneratedDocumentMetadataProviderAttribute>()
|
||||
.ToArray();
|
||||
|
||||
Assert.That(providers, Is.Not.Empty);
|
||||
|
||||
var definitions = providers
|
||||
.SelectMany(provider =>
|
||||
((ICultGeneratedDocumentMetadataProvider)Activator.CreateInstance(provider.ProviderType)!)
|
||||
.GetDocumentDefinitions())
|
||||
.ToArray();
|
||||
|
||||
var named = definitions.Single(definition => definition.DocumentType == typeof(NamedTestEntry));
|
||||
Assert.That(named.SchemaName, Is.EqualTo("tests.named_entry"));
|
||||
Assert.That(named.NameMember, Is.EqualTo(nameof(NamedTestEntry.Name)));
|
||||
|
||||
var referenceHolder = definitions.Single(definition => definition.DocumentType == typeof(ReferenceHolderEntry));
|
||||
var parentMember = referenceHolder.Members.Single(member => member.MemberName == nameof(ReferenceHolderEntry.Parent));
|
||||
Assert.That(parentMember.IsReference, Is.True);
|
||||
Assert.That(parentMember.TargetSchemaName, Is.EqualTo("tests.named_entry"));
|
||||
Assert.That(parentMember.TypeName, Does.Contain("CultRecordRef"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Registry_CanonicalSchemaJson_Tracks_Reference_Metadata()
|
||||
{
|
||||
var descriptor = CultDocumentRegistry.Shared.GetRequired<ReferenceHolderEntry>();
|
||||
|
||||
Assert.That(descriptor.CanonicalSchemaJson, Does.Contain("\"targetSchemaName\":\"tests.named_entry\""));
|
||||
Assert.That(descriptor.CanonicalSchemaJson, Does.Contain("\"isReference\":true"));
|
||||
}
|
||||
|
||||
[CultDocument("tests.named_entry", "tests.named_entry.v1")]
|
||||
[MessagePackObject]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class NamedTestEntry
|
||||
{
|
||||
[Key(0)]
|
||||
|
|
@ -159,5 +195,16 @@ namespace GameCult.Caching.Tests
|
|||
[Key(1)]
|
||||
public string Value = string.Empty;
|
||||
}
|
||||
|
||||
[CultDocument("tests.reference_holder", "tests.reference_holder.v1")]
|
||||
internal sealed class ReferenceHolderEntry
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string Name = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
public CultRecordRef<NamedTestEntry> Parent = new(new CultRecordKey("parent"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\..\src\GameCult.Caching.NewtonsoftJson\GameCult.Caching.NewtonsoftJson.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameCult.Networking\GameCult.Networking.csproj" />
|
||||
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Reference in a new issue