Generate CultCache document metadata providers

This commit is contained in:
Meta Vens 2026-05-08 01:50:15 +01:00
commit 8606b74d65
11 changed files with 897 additions and 105 deletions

View file

@ -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; }
}
}
}

View file

@ -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>

View file

@ -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)

View 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));
}
}

View file

@ -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

View file

@ -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,

View 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();
}
}
}

View file

@ -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

View file

@ -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"));
}
}
}

View file

@ -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>

View file

@ -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>