diff --git a/CultLib.sln b/CultLib.sln index c60890ca..fbc20922 100644 --- a/CultLib.sln +++ b/CultLib.sln @@ -11,10 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "src\Gam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Generator", "src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj", "{0E990C96-3808-450E-9349-A0C3262195ED}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Analyzers", "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "{DBE06954-EC85-4034-9049-72C7D27556DF}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking", "src\GameCult.Networking\GameCult.Networking.csproj", "{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh", "src\GameCult.Mesh\GameCult.Mesh.csproj", "{8D1B01A7-BF9E-44E6-88E1-B17255728B42}" @@ -99,30 +95,6 @@ Global {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x64.Build.0 = Release|Any CPU {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.ActiveCfg = Release|Any CPU {26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.Build.0 = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.ActiveCfg = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.Build.0 = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.ActiveCfg = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.Build.0 = Debug|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.Build.0 = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.ActiveCfg = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.Build.0 = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.ActiveCfg = Release|Any CPU - {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.Build.0 = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.ActiveCfg = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.Build.0 = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.ActiveCfg = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.Build.0 = Debug|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.Build.0 = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.ActiveCfg = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.Build.0 = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.ActiveCfg = Release|Any CPU - {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.Build.0 = Release|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.Build.0 = Debug|Any CPU {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -311,8 +283,6 @@ Global {72AEDD42-E881-4EB7-99A8-FD3B9667189C} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {9DDA917D-B4DB-4AC1-9B9A-4ECF06A5ABA1} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {26EE4C38-F06E-4E7A-A89C-908318BCB0DB} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} - {0E990C96-3808-450E-9349-A0C3262195ED} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} - {DBE06954-EC85-4034-9049-72C7D27556DF} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {8D1B01A7-BF9E-44E6-88E1-B17255728B42} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} diff --git a/README.md b/README.md index aba30282..4977a02a 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,6 @@ The solution includes: - `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions - `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache - `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache -- `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models -- `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects - `GameCult.Math` / CultMath: portable numeric primitives, deterministic random, noise, and shader-parity helpers - `GameCult.Geometry` / CultGeometry: engine-neutral geometry algorithms plus CultCache-native domain, selected-cut, and mesh artifact documents - `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions @@ -128,8 +126,6 @@ src/ GameCult.Caching/ GameCult.Caching.MessagePack/ GameCult.Caching.NewtonsoftJson/ - GameCult.Caching.MessagePack.Generator/ - GameCult.Caching.MessagePack.Analyzers/ GameCult.Geometry/ GameCult.Networking/ GameCult.Networking.WebSockets/ @@ -349,8 +345,6 @@ Each subproject has a local README with package-specific detail: - [GameCult.Caching](src/GameCult.Caching/README.md) - [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md) - [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md) -- [GameCult.Caching.MessagePack.Generator](src/GameCult.Caching.MessagePack.Generator/README.md) -- [GameCult.Caching.MessagePack.Analyzers](src/GameCult.Caching.MessagePack.Analyzers/README.md) - [GameCult.Geometry](src/GameCult.Geometry/README.md) - [GameCult.Networking](src/GameCult.Networking/README.md) - [GameCult.Mesh](src/GameCult.Mesh/README.md) diff --git a/scripts/pack-nuget.ps1 b/scripts/pack-nuget.ps1 index 2b853aab..95e5cd2f 100644 --- a/scripts/pack-nuget.ps1 +++ b/scripts/pack-nuget.ps1 @@ -16,7 +16,6 @@ $outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $projects = @( "src\GameCult.Logging\GameCult.Logging.csproj", "src\GameCult.Caching\GameCult.Caching.csproj", - "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "src\GameCult.Networking\GameCult.Networking.csproj", "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", @@ -53,7 +52,6 @@ $version = [regex]::Match($meshPackage.Name, '^GameCult\.Mesh\.(.+)\.nupkg$').Gr $expectedPackages = @( "GameCult.Logging.$version.nupkg", "GameCult.Caching.$version.nupkg", - "GameCult.Caching.MessagePack.Analyzers.$version.nupkg", "GameCult.Caching.MessagePack.$version.nupkg", "GameCult.Networking.$version.nupkg", "GameCult.Networking.WebSockets.$version.nupkg", diff --git a/src/GameCult.Caching.MessagePack.Analyzers/Class1.cs b/src/GameCult.Caching.MessagePack.Analyzers/Class1.cs deleted file mode 100644 index 345e38e6..00000000 --- a/src/GameCult.Caching.MessagePack.Analyzers/Class1.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace GameCult.Caching.MessagePack.Analyzer -{ - /// - /// Placeholder type for the analyzer packaging project. - /// - public class Class1 - { - } -} diff --git a/src/GameCult.Caching.MessagePack.Analyzers/GameCult.Caching.MessagePack.Analyzers.csproj b/src/GameCult.Caching.MessagePack.Analyzers/GameCult.Caching.MessagePack.Analyzers.csproj deleted file mode 100644 index 12c4e684..00000000 --- a/src/GameCult.Caching.MessagePack.Analyzers/GameCult.Caching.MessagePack.Analyzers.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - - Source generator that emits MessagePack formatters for DatabaseEntry subclasses - MessagePack;SourceGenerator;DatabaseEntry;Analyzer - - false - $(TargetsForTfmSpecificContentInPackage);PackBuildOutputs - true - false - true - - - - - - - - - - - - - - - - - - - - diff --git a/src/GameCult.Caching.MessagePack.Analyzers/README.md b/src/GameCult.Caching.MessagePack.Analyzers/README.md deleted file mode 100644 index e4b25bd4..00000000 --- a/src/GameCult.Caching.MessagePack.Analyzers/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# GameCult.Caching.MessagePack.Analyzers - -`GameCult.Caching.MessagePack.Analyzers` is the delivery mechanism for the MessagePack source generator used by the cache libraries. - -## Scope - -This project is primarily packaging infrastructure. It exists so consuming projects can receive generator output at compile time without treating the generator assembly as a normal runtime dependency. - -## What It Does - -- references the generator project -- arranges for the generator assembly to be packed under `analyzers/dotnet/cs` -- keeps generator assets out of the normal compile/runtime reference flow - -## Practical Meaning - -When a consuming project adds the analyzer package: - -- the generator runs during compilation -- concrete `DatabaseEntry` types receive generated MessagePack formatters -- `GameCult.Caching.MessagePack` can then serialize those types without handwritten formatter code - -## Current State - -This project is a packaging host. It does not currently expose custom analyzer diagnostics of its own. diff --git a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs deleted file mode 100644 index ad7f934e..00000000 --- a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs +++ /dev/null @@ -1,716 +0,0 @@ -#nullable enable -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; - -namespace GameCult.Caching.MessagePack.Generator -{ -/// -/// Emits MessagePack payload serializers for CultCache document types. -/// -[Generator] -public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator -{ - /// - /// Registers the generator pipeline. - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var documentShapes = context.SyntaxProvider - .CreateSyntaxProvider( - static (node, _) => node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0, - static (ctx, _) => BuildDocumentShape(ctx)) - .Where(static shape => shape != null) - .Select(static (shape, _) => shape!); - - var compilationAndShapes = context.CompilationProvider.Combine(documentShapes.Collect()); - context.RegisterSourceOutput( - compilationAndShapes, - static (spc, pair) => EmitProvider(spc, pair.Left, pair.Right)); - } - - private static DocumentShape? BuildDocumentShape(GeneratorSyntaxContext context) - { - if (context.SemanticModel.GetDeclaredSymbol(context.Node) is not INamedTypeSymbol typeSymbol) - { - 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 rejections = new List(); - var members = DiscoverMembers(typeSymbol, rejections); - var nameMember = members.FirstOrDefault(member => member.IsName); - var canConstructForDeserialization = typeSymbol.TypeKind == TypeKind.Struct || - typeSymbol.InstanceConstructors.Any(constructor => - constructor.Parameters.Length == 0 && - constructor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal); - var hasDenseSlots = members - .OrderBy(member => member.Slot) - .Select((member, index) => member.Slot == index) - .All(matches => matches); - 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, - canConstructForDeserialization, - hasDenseSlots, - rejections.ToImmutableArray(), - typeSymbol.Locations.FirstOrDefault()); - } - - // Mirrors CultDocumentRegistry.DiscoverMembers step for step: public instance fields and get/set properties, most-derived - // declaration first, a property's attributes resolved up its override chain, rejections in the same order and text. - private static ImmutableArray DiscoverMembers(INamedTypeSymbol typeSymbol, List rejections) - { - var documentName = typeSymbol.Name; - var documentStem = SanitizeIdentifier(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - .Replace("global::", string.Empty).Replace(".", "_")); - var candidates = new List(); - var seenRoots = new HashSet(SymbolEqualityComparer.Default); - var depth = 0; - for (var type = typeSymbol; type != null && type.SpecialType != SpecialType.System_Object; type = type.BaseType, depth++) - { - foreach (var member in type.GetMembers()) - { - if (member is IFieldSymbol field && !field.IsStatic && !field.IsConst && !field.IsImplicitlyDeclared && - field.DeclaredAccessibility == Accessibility.Public) - { - if (!IsIgnored(field)) - candidates.Add(new MemberCandidate(field, field.Type, depth, !field.IsReadOnly, null)); - } - else if (member is IPropertySymbol property && !property.IsStatic && !property.IsIndexer && - property.DeclaredAccessibility == Accessibility.Public && seenRoots.Add(RootOf(property))) - { - var setter = OverrideChain(property).Select(declaration => declaration.SetMethod).FirstOrDefault(method => method != null); - if (OverrideChain(property).All(declaration => declaration.GetMethod == null) || setter == null) - continue; - var root = RootOf(property); - var divergentRoot = OverrideChain(property).Any(declaration => - !Equals(KeyValue(declaration), KeyValue(root)) || IsIgnored(declaration) != IsIgnored(root)) - ? root - : null; - if (divergentRoot == null && IsIgnored(property)) - continue; - candidates.Add(new MemberCandidate(property, property.Type, depth, - !setter.IsInitOnly && setter.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal, - divergentRoot)); - } - } - } - - var keyed = new List<(MemberCandidate Candidate, int Slot)>(); - foreach (var candidate in candidates.OrderBy(candidate => candidate.Depth).ThenBy(candidate => candidate.Member.Name, StringComparer.Ordinal)) - { - var keyValue = KeyValue(candidate.Member); - if (candidate.DivergentRoot != null) - rejections.Add(DivergentOverrideMessage(documentName, Qualified(candidate.Member), Qualified(candidate.DivergentRoot))); - else if (keyValue is string) - rejections.Add(StringKeyMessage(documentName, candidate.Member.Name)); - else if (keyValue is not int slot) - rejections.Add(UnkeyedMemberMessage(documentName, candidate.Member.Name)); - else - { - if (!candidate.Writable) - rejections.Add(NotWritableMessage(documentName, candidate.Member.Name)); - keyed.Add((candidate, slot)); - } - } - - foreach (var group in keyed.GroupBy(entry => entry.Slot).Where(group => group.Count() > 1).OrderBy(group => group.Key)) - { - var pair = group.Take(2).ToArray(); - rejections.Add(DuplicateSlotMessage(documentName, Qualified(pair[0].Candidate.Member), Qualified(pair[1].Candidate.Member), group.Key)); - } - - foreach (var group in keyed.GroupBy(entry => entry.Candidate.Member.Name) - .Where(group => group.Select(entry => entry.Slot).Distinct().Count() > 1) - .OrderBy(group => group.Key, StringComparer.Ordinal)) - { - var pair = group.Take(2).ToArray(); - rejections.Add(HiddenMemberMessage(documentName, Qualified(pair[0].Candidate.Member), Qualified(pair[1].Candidate.Member))); - } - - return keyed - .Select(entry => MemberShapeSeed.From(entry.Candidate.Member, entry.Candidate.Type).WithSlot(entry.Slot, documentStem)) - .OrderBy(member => member.Slot) - .ToImmutableArray(); - } - - private static IPropertySymbol RootOf(IPropertySymbol property) - { - while (property.OverriddenProperty != null) - property = property.OverriddenProperty; - return property; - } - - private static IEnumerable OverrideChain(IPropertySymbol property) - { - for (IPropertySymbol? declaration = property; declaration != null; declaration = declaration.OverriddenProperty) - yield return declaration; - } - - private static string Qualified(ISymbol member) => member.ContainingType.Name + "." + member.Name; - - // CultDocumentRegistry throws the same texts. - private static string UnkeyedMemberMessage(string documentTypeName, string memberName) => - $"Cult document {documentTypeName} member {memberName} has no [Key]; every persisted member of a [CultDocument] type needs an explicit [Key(n)]."; - - private static string StringKeyMessage(string documentTypeName, string memberName) => - $"Cult document {documentTypeName} member {memberName} has a string [Key]; string keys are not supported; use integer [Key(n)]."; - - private static string NotWritableMessage(string documentTypeName, string memberName) => - $"Cult document {documentTypeName} member {memberName} is not writable; a persisted member needs a non-readonly field or a public or internal set accessor."; - - private static string DuplicateSlotMessage(string documentTypeName, string first, string second, int slot) => - $"Cult document {documentTypeName} members {first} and {second} share [Key({slot})]; every persisted member needs a distinct [Key(n)]."; - - private static string DivergentOverrideMessage(string documentTypeName, string member, string root) => - $"Cult document {documentTypeName} member {member} overrides {root} with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them."; - - private static object? KeyValue(ISymbol member) - { - var key = GetMemberAttribute(member, "MessagePack.KeyAttribute"); - return key != null && key.ConstructorArguments.Length > 0 ? key.ConstructorArguments[0].Value : null; - } - - private static string HiddenMemberMessage(string documentTypeName, string first, string second) => - $"Cult document {documentTypeName} member {first} hides persisted member {second}; persisted member names must be unique."; - -#pragma warning disable RS2008 - private static readonly DiagnosticDescriptor RejectedMember = new( - "GCC001", "Cult document member cannot be persisted", "{0}", "GameCult.Caching", DiagnosticSeverity.Error, isEnabledByDefault: true); -#pragma warning restore RS2008 - - private static bool IsIgnored(ISymbol member) - { - return GetMemberAttribute(member, "MessagePack.IgnoreMemberAttribute") != null; - } - - private static AttributeData? GetAttribute(ISymbol symbol, string fullyQualifiedName) - { - return symbol.GetAttributes().FirstOrDefault(attribute => - attribute.AttributeClass?.ToDisplayString() == fullyQualifiedName); - } - - // Attribute.GetCustomAttributes(inherit: true) semantics: an override without the attribute inherits its base declaration's. - private static AttributeData? GetMemberAttribute(ISymbol member, string fullyQualifiedName) - { - if (member is not IPropertySymbol property) - return GetAttribute(member, fullyQualifiedName); - return OverrideChain(property).Select(declaration => GetAttribute(declaration, fullyQualifiedName)).FirstOrDefault(attribute => attribute != null); - } - - private static void EmitProvider(SourceProductionContext context, Compilation compilation, ImmutableArray shapes) - { - var shaped = shapes - .Where(shape => shape != null) - .GroupBy(shape => shape.DocumentTypeName, StringComparer.Ordinal) - .Select(group => group.First()) - .OrderBy(shape => shape.DocumentTypeName, StringComparer.Ordinal) - .ToArray(); - foreach (var shape in shaped) - { - foreach (var message in shape.Rejections) - context.ReportDiagnostic(Diagnostic.Create(RejectedMember, shape.Location, message)); - } - - var documents = shaped.Where(shape => shape.Rejections.IsEmpty).ToArray(); - - if (documents.Length == 0) - { - return; - } - - var canEmitPayloadCodecs = - compilation.GetTypeByMetadataName("MessagePack.MessagePackWriter") != null && - compilation.GetTypeByMetadataName("GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization") != null; - - var providerName = $"GeneratedCultDocumentMetadataProvider_{SanitizeIdentifier(compilation.AssemblyName ?? "GameCult")}"; - var builder = new StringBuilder(); - builder.AppendLine("// "); - 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(canEmitPayloadCodecs && document.HasDenseSlots - ? $" {document.PayloadSerializerMethodName}," - : " null,"); - builder.AppendLine(canEmitPayloadCodecs && document.HasDenseSlots && document.CanConstructForDeserialization - ? $" {document.PayloadDeserializerMethodName}," - : " null,"); - 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.OrderBy(member => member.Slot)) - { - builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentMemberDefinition("); - builder.AppendLine($" \"{Escape(member.Name)}\","); - builder.AppendLine($" {member.Slot},"); - builder.AppendLine($" \"{Escape(member.SchemaTypeName)}\","); - 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 GetDocumentDefinitions()"); - builder.AppendLine(" {"); - builder.AppendLine(" return s_documents;"); - builder.AppendLine(" }"); - builder.AppendLine(); - - foreach (var document in documents) - { - if (canEmitPayloadCodecs && document.HasDenseSlots) - { - EmitPayloadSerializer(builder, document); - if (document.CanConstructForDeserialization) - { - EmitPayloadDeserializer(builder, document); - } - } - - 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 void EmitPayloadSerializer(StringBuilder builder, DocumentShape document) - { - builder.AppendLine($" private static byte[] {document.PayloadSerializerMethodName}(object document)"); - builder.AppendLine(" {"); - builder.AppendLine($" var typed = ({document.DocumentTypeName})document;"); - builder.AppendLine($" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof({document.DocumentTypeName}).Assembly);"); - builder.AppendLine(" var buffer = new global::System.Buffers.ArrayBufferWriter();"); - builder.AppendLine(" var writer = new global::MessagePack.MessagePackWriter(buffer);"); - builder.AppendLine($" writer.WriteArrayHeader({document.Members.Length});"); - foreach (var member in document.Members.OrderBy(member => member.Slot)) - { - builder.AppendLine($" global::MessagePack.FormatterResolverExtensions.GetFormatterWithVerify<{member.TypeSyntaxName}>(options.Resolver).Serialize(ref writer, typed.{member.Name}, options);"); - } - builder.AppendLine(" writer.Flush();"); - builder.AppendLine(" return buffer.WrittenSpan.ToArray();"); - builder.AppendLine(" }"); - builder.AppendLine(); - } - - private static void EmitPayloadDeserializer(StringBuilder builder, DocumentShape document) - { - builder.AppendLine($" private static object {document.PayloadDeserializerMethodName}(byte[] payload)"); - builder.AppendLine(" {"); - builder.AppendLine($" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.OptionsFor(typeof({document.DocumentTypeName}).Assembly);"); - builder.AppendLine(" var reader = new global::MessagePack.MessagePackReader(payload);"); - builder.AppendLine(" var count = reader.ReadArrayHeader();"); - builder.AppendLine($" var value = new {document.DocumentTypeName}();"); - foreach (var member in document.Members.OrderBy(member => member.Slot)) - { - builder.AppendLine($" if (count > {member.Slot})"); - builder.AppendLine(" {"); - builder.AppendLine($" value.{member.Name} = global::MessagePack.FormatterResolverExtensions.GetFormatterWithVerify<{member.TypeSyntaxName}>(options.Resolver).Deserialize(ref reader, options);"); - builder.AppendLine(" }"); - } - builder.AppendLine($" for (var index = {document.Members.Length}; index < count; index++)"); - builder.AppendLine(" {"); - builder.AppendLine(" reader.Skip();"); - builder.AppendLine(" }"); - builder.AppendLine(" return value;"); - builder.AppendLine(" }"); - builder.AppendLine(); - } - - 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(); - 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 MemberCandidate - { - public MemberCandidate(ISymbol member, ITypeSymbol type, int depth, bool writable, IPropertySymbol? divergentRoot) - { - Member = member; - Type = type; - Depth = depth; - Writable = writable; - DivergentRoot = divergentRoot; - } - - public ISymbol Member { get; } - public ITypeSymbol Type { get; } - public int Depth { get; } - public bool Writable { get; } - public IPropertySymbol? DivergentRoot { get; } - } - - private sealed class MemberShapeSeed - { - private MemberShapeSeed( - string name, - string schemaTypeName, - string typeSyntaxName, - bool isName, - string? indexAlias, - bool isReference, - bool isMany, - bool canBeNull, - string? targetSchemaName) - { - Name = name; - SchemaTypeName = schemaTypeName; - TypeSyntaxName = typeSyntaxName; - IsName = isName; - IndexAlias = indexAlias; - IsReference = isReference; - IsMany = isMany; - CanBeNull = canBeNull; - TargetSchemaName = targetSchemaName; - } - - public string Name { get; } - public string SchemaTypeName { get; } - public string TypeSyntaxName { 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; } - - // Accessors are named per document: one provider class holds every document of the assembly, and documents sharing a - // base member would otherwise emit the same method twice. - public MemberShape WithSlot(int slot, string documentStem) - { - var accessorStem = documentStem + "_" + SanitizeIdentifier(Name); - return new MemberShape( - Name, - slot, - SchemaTypeName, - TypeSyntaxName, - 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 = GetMemberAttribute(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 = GetMemberAttribute(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), - memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - GetMemberAttribute(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 sealed class DocumentShape - { - public DocumentShape( - string documentTypeName, - string schemaName, - string schemaVersion, - bool isGlobal, - string? nameMember, - string? nameAccessorMethodName, - ImmutableArray indexAccessors, - ImmutableArray members, - bool canConstructForDeserialization, - bool hasDenseSlots, - ImmutableArray rejections, - Location? location) - { - Rejections = rejections; - Location = location; - DocumentTypeName = documentTypeName; - SchemaName = schemaName; - SchemaVersion = schemaVersion; - IsGlobal = isGlobal; - NameMember = nameMember; - NameAccessorMethodName = nameAccessorMethodName; - var safeStem = SanitizeIdentifier(documentTypeName.Replace("global::", string.Empty).Replace(".", "_")); - PayloadSerializerMethodName = $"Serialize_{safeStem}_Payload"; - PayloadDeserializerMethodName = $"Deserialize_{safeStem}_Payload"; - IndexAccessors = indexAccessors; - Members = members; - CanConstructForDeserialization = canConstructForDeserialization; - HasDenseSlots = hasDenseSlots; - } - - 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 string PayloadSerializerMethodName { get; } - public string PayloadDeserializerMethodName { get; } - public ImmutableArray IndexAccessors { get; } - public ImmutableArray Members { get; } - public bool CanConstructForDeserialization { get; } - public bool HasDenseSlots { get; } - public ImmutableArray Rejections { get; } - public Location? Location { 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 schemaTypeName, - string typeSyntaxName, - bool isReference, - bool isMany, - string? targetSchemaName, - bool isName, - string? indexAlias, - bool canBeNull, - string? accessorMethodName, - string? indexAccessorMethodName) - { - Name = name; - Slot = slot; - SchemaTypeName = schemaTypeName; - TypeSyntaxName = typeSyntaxName; - 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 SchemaTypeName { get; } - public string TypeSyntaxName { 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; } - } - } -} diff --git a/src/GameCult.Caching.MessagePack.Generator/GameCult.Caching.MessagePack.Generator.csproj b/src/GameCult.Caching.MessagePack.Generator/GameCult.Caching.MessagePack.Generator.csproj deleted file mode 100644 index 1a6e372e..00000000 --- a/src/GameCult.Caching.MessagePack.Generator/GameCult.Caching.MessagePack.Generator.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - true - enable - false - netstandard2.0 - true - - - - - - - - diff --git a/src/GameCult.Caching.MessagePack.Generator/README.md b/src/GameCult.Caching.MessagePack.Generator/README.md deleted file mode 100644 index df3e0ecc..00000000 --- a/src/GameCult.Caching.MessagePack.Generator/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# GameCult.Caching.MessagePack.Generator - -A Roslyn incremental source generator that emits CultCache document metadata, -and MessagePack payload codecs where it can, for classes marked -`[CultDocument]`. - -## Scope - -Infrastructure for the caching stack. Application code does not reference it -directly; it arrives through `GameCult.Caching.MessagePack.Analyzers`. Without -it, `CultDocumentRegistry` builds descriptors by reflection, and the two differ: -reflective descriptors include inherited members and order unkeyed members by -declaration token; generated descriptors include only declared members and -order unkeyed members by name. For derived documents or unkeyed members the two -produce different schema ids. This is scheduled to be fixed in Cut 4. - -## What It Generates - -One `GeneratedCultDocumentMetadataProvider_` per assembly, registered -with `[assembly: CultGeneratedDocumentMetadataProvider]`, holding for each -`[CultDocument]` class: - -- schema name, schema version, and whether the type is `[CultGlobal]` -- member definitions (name, slot, persisted type name, reference and - cardinality, `[CultName]`, `[CultIndex]`) -- name and index accessors -- a payload serializer, and a deserializer when the type has a public or - internal parameterless constructor - -Members are the public, non-static, non-`[IgnoreMember]` fields and settable -properties **declared on the class itself**; inherited members are not -discovered. Members with `[Key(n)]` take slot `n`; unkeyed members follow the -highest key in ordinal name order. Annotate every persisted member: source -order is not a schema. - -Payload codecs are emitted only when slots are dense (`0..n-1`) and the -compilation references MessagePack and `GameCult.Caching.MessagePack`. -Otherwise the definition carries no codec and serialization falls back to the -reflective path. A generated payload is an array of the members in slot order; -the deserializer leaves missing trailing slots at their initializer values and -skips unknown extra slots. Generated codecs read -`CultDocumentMessagePackSerialization.Options` at call time. - -The generator has no `[Union]` handling. - -## When You Need To Think About This Package - -- a document's descriptor or schema id differs between generated and - reflective builds -- a document serializes through the reflective path when you expected a codec -- you are debugging build-time source generation - -## Distribution - -Packaged for consumers through -[../GameCult.Caching.MessagePack.Analyzers/README.md](../GameCult.Caching.MessagePack.Analyzers/README.md). diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 78e3eabf..1e99125e 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -77,15 +77,7 @@ public static class CultDocumentMessagePackSerialization public static byte[] SerializeUntyped(object value, Type type, CultDocumentRegistry registry) { if (registry == null) throw new ArgumentNullException(nameof(registry)); - if (value != null) - { - var descriptor = registry.GetRequired(type); - if (descriptor.GeneratedPayloadSerializer != null) - { - return descriptor.GeneratedPayloadSerializer(value); - } - } - + if (value != null) registry.GetRequired(type); return MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly)); } @@ -97,12 +89,7 @@ public static class CultDocumentMessagePackSerialization public static object DeserializeUntyped(Type type, byte[] payload, CultDocumentRegistry registry) { if (registry == null) throw new ArgumentNullException(nameof(registry)); - var descriptor = registry.GetRequired(type); - if (descriptor.GeneratedPayloadDeserializer != null) - { - return descriptor.GeneratedPayloadDeserializer(payload); - } - + registry.GetRequired(type); return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type.Assembly)) ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); } diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index b089c776..794936bd 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -6,9 +6,6 @@ - - diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index 660da700..c69ab887 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -10,16 +10,16 @@ format for the attribute-first `GameCult.Caching` stack. - `CultDocumentResolver` - `CultRecordRefFormatter` -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. Cult documents then -serialize through explicit generated slot codecs, while the store snapshot -format is written by hand against `MessagePackWriter`/`MessagePackReader`. +Cult document payloads serialize through `MessagePackSerializer` with the +options of the document's assembly (`CultDocumentMessagePackSerialization.OptionsFor`), +after the reflective `CultDocumentRegistry` has accepted the document shape. The +store snapshot format is written by hand against +`MessagePackWriter`/`MessagePackReader`. ## What It Does - stores whole CultCache snapshots in MessagePack -- serializes plain attributed document payloads through explicit generated slot codecs +- serializes attributed document payloads as MessagePack `[Key(n)]` arrays - keeps explicit `CultRecordRef` values compact on disk/wire - preserves the cache/store split where metadata lives outside the domain model @@ -54,7 +54,5 @@ await cache.PullAllBackingStoresAsync(); - Payload bytes are MessagePack. Store metadata and schema catalogs are also persisted through hand-written MessagePack array layouts in this package. -- The generator package now targets attributed document types instead of - `DatabaseEntry` subclasses. - The raw CultNet document lane should treat these payload bytes as already blessed, not decode and re-encode them for sport. diff --git a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md index 17609b15..b3cd9f43 100644 --- a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md +++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md @@ -30,8 +30,14 @@ documents and the expected receipts. A member's slot is its MessagePack `[Key(n)]` integer. MessagePack's `[Key]` and `[IgnoreMember]` are the single slot authority, so `GameCult.Caching` depends on -`MessagePack.Annotations`. The registry and the generator (diagnostic GCC001) -refuse, with the same message: a persisted member without `[Key]`, a string +`MessagePack.Annotations`. Payloads are written by `MessagePackSerializer`, so +the registry refuses a document type without `[MessagePackObject]` and a +non-public document type without `[MessagePackObject(AllowPrivate = true)]`. +`AllowPrivate` makes MessagePack read non-public members too, so every +non-public instance field or property of such a type must be `[IgnoreMember]`; +the registry persists public members only. A +member's type name is the CLR full name; a nested type is written `Outer+Inner`. +The registry also refuses a persisted member without `[Key]`, a string `[Key]`, two members sharing a slot (including `new`-hidden members), a hidden persisted member, an override whose `[Key]` or `[IgnoreMember]` differs from its base declaration's (MessagePack reads the base declaration), and a persisted diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 636f3ba6..697bf959 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -213,8 +213,7 @@ another runtime need not obey the `storedAt` rule. - Options belong to the assembly that declares a document. That assembly names its formatter resolvers with `[assembly: CultCacheFormatterResolver(typeof(R))]`; - generated and reflective serializers resolve options from the document's - assembly. There is no mutable static, no registration call, and no load-order + the payload serializer resolves options from the document's assembly. There is no mutable static, no registration call, and no load-order rule. - A generic document's options come from the generic definition's assembly: `Doc` uses the resolvers `Doc`'s assembly declares, never `X`'s. A formatter diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 57302419..8f0a5c80 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -12,6 +12,7 @@ using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; +using MessagePack; using R3; namespace GameCult.Caching @@ -120,8 +121,6 @@ namespace GameCult.Caching bool isGlobal, string? nameMember, Func? nameAccessor, - Func? generatedPayloadSerializer, - Func? generatedPayloadDeserializer, IReadOnlyDictionary> indexAccessors, IReadOnlyList members) { @@ -134,8 +133,6 @@ namespace GameCult.Caching IsGlobal = isGlobal; NameMember = nameMember; NameAccessor = nameAccessor; - GeneratedPayloadSerializer = generatedPayloadSerializer; - GeneratedPayloadDeserializer = generatedPayloadDeserializer; IndexAccessors = indexAccessors; Members = members; } @@ -149,8 +146,6 @@ namespace GameCult.Caching public bool IsGlobal { get; } public string? NameMember { get; } internal Func? NameAccessor { get; } - public Func? GeneratedPayloadSerializer { get; } - public Func? GeneratedPayloadDeserializer { get; } internal IReadOnlyDictionary> IndexAccessors { get; } internal IReadOnlyList Members { get; } @@ -264,15 +259,7 @@ namespace GameCult.Caching lock (_registrationGate) { var rebuilt = new RegistryIndexes(); - var generatedTypes = new HashSet(); - foreach (var definition in CultGeneratedDocumentMetadataLoader.LoadDefinitions()) - { - var descriptor = BuildDescriptor(definition); - RegisterDescriptor(rebuilt, descriptor); - generatedTypes.Add(descriptor.DocumentType); - } - - foreach (var type in ReflectionExtensions.GetAttributedDocumentTypes().Where(type => !generatedTypes.Contains(type))) + foreach (var type in ReflectionExtensions.GetAttributedDocumentTypes()) { RegisterDescriptor(rebuilt, BuildDescriptor(type)); } @@ -288,12 +275,6 @@ namespace GameCult.Caching return descriptor; } - descriptor = TryBuildGeneratedDescriptor(type); - if (descriptor != null) - { - return RegisterDescriptor(descriptor); - } - var attribute = type.GetCustomAttribute(); if (attribute == null) { @@ -472,16 +453,7 @@ namespace GameCult.Caching public Dictionary BySchemaName { get; } } - private static CultDocumentDescriptor? TryBuildGeneratedDescriptor(Type type) - { - var definition = CultGeneratedDocumentMetadataLoader.LoadDefinitions(type.Assembly) - .FirstOrDefault(candidate => candidate.DocumentType == type); - return definition == null - ? null - : BuildDescriptor(definition); - } - - internal static CultDocumentDescriptor BuildDescriptor(Type type) + private static CultDocumentDescriptor BuildDescriptor(Type type) { var attribute = type.GetCustomAttribute() ?? throw new InvalidOperationException( @@ -522,49 +494,10 @@ namespace GameCult.Caching type.GetCustomAttribute() != null, nameMember?.Member.Name, nameMember?.GetterNullable, - null, - null, indexAccessors, descriptorMembers); } - private static CultDocumentDescriptor BuildDescriptor(CultGeneratedDocumentDefinition definition) - { - var descriptorMembers = definition.Members - .OrderBy(member => member.Slot) - .Select(member => new CultDocumentMemberDescriptor - { - MemberName = member.MemberName, - Slot = member.Slot, - TypeName = member.TypeName, - IsReference = member.IsReference, - IsMany = member.IsMany, - 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.SerializePayload, - definition.DeserializePayload, - definition.IndexAccessors.ToDictionary(accessor => accessor.Alias, accessor => accessor.Accessor, StringComparer.Ordinal), - descriptorMembers); - } - private static string BuildSemanticFingerprint( string schemaName, string schemaVersion, @@ -900,7 +833,6 @@ namespace GameCult.Caching public int[] DefaultedMissingSlots { get; } } - // CultDocumentMessagePackGenerator.DiscoverMembers mirrors this step for step and reports the same rejections as GCC001. // Public instance fields and get/set properties, most-derived declaration first; GetCustomAttributes inherits a property's // attributes up its override chain, as MessagePack's own resolver reads them. private static IReadOnlyList DiscoverMembers(Type type) @@ -938,7 +870,25 @@ namespace GameCult.Caching } var rejections = new List(); - var keyed = new List<(MemberInfo Member, Type MemberType, int Slot)>(); + var messagePackObject = type.GetCustomAttribute(true); + if (messagePackObject == null) + rejections.Add(NotMessagePackObjectMessage(type.Name)); + else if (!type.IsVisible && !messagePackObject.AllowPrivate) + rejections.Add(NotVisibleMessage(type.Name)); + else if (messagePackObject.AllowPrivate) + { + // AllowPrivate makes MessagePack read non-public members; the registry persists public members only. + const BindingFlags nonPublic = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.NonPublic; + for (var current = type; current != null && current != typeof(object); current = current.BaseType) + { + foreach (var member in current.GetFields(nonPublic).Cast().Concat(current.GetProperties(nonPublic))) + { + if (!member.IsDefined(typeof(CompilerGeneratedAttribute)) && !IsIgnored(member)) + rejections.Add(UnmarkedNonPublicMemberMessage(type.Name, Qualified(member))); + } + } + } + var keyed =new List<(MemberInfo Member, Type MemberType, int Slot)>(); foreach (var candidate in candidates.OrderBy(candidate => candidate.Depth).ThenBy(candidate => candidate.Member.Name, StringComparer.Ordinal)) { var key = GetKeyValue(candidate.Member); @@ -1003,6 +953,15 @@ namespace GameCult.Caching private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name; + private static string NotMessagePackObjectMessage(string documentTypeName) => + $"Cult document {documentTypeName} is not a MessagePack object; add [MessagePackObject] so MessagePack serializes its [Key(n)] members."; + + private static string NotVisibleMessage(string documentTypeName) => + $"Cult document {documentTypeName} is not public; MessagePack serializes a non-public type only with [MessagePackObject(AllowPrivate = true)], so make it public or set AllowPrivate."; + + private static string UnmarkedNonPublicMemberMessage(string documentTypeName, string memberName) => + $"Cult document {documentTypeName} member {memberName} is non-public; [MessagePackObject(AllowPrivate = true)] makes MessagePack read it, so mark it [IgnoreMember]."; + internal static string UnkeyedMemberMessage(string documentTypeName, string memberName) => $"Cult document {documentTypeName} member {memberName} has no [Key]; every persisted member of a [CultDocument] type needs an explicit [Key(n)]."; diff --git a/src/GameCult.Caching/CultGeneratedDocumentMetadata.cs b/src/GameCult.Caching/CultGeneratedDocumentMetadata.cs deleted file mode 100644 index c0bb601d..00000000 --- a/src/GameCult.Caching/CultGeneratedDocumentMetadata.cs +++ /dev/null @@ -1,215 +0,0 @@ -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 GetDocumentDefinitions(); - } - - public sealed class CultGeneratedDocumentDefinition - { - public CultGeneratedDocumentDefinition( - Type documentType, - string schemaName, - string schemaVersion, - bool isGlobal, - string? nameMember, - Func? nameAccessor, - Func? serializePayload, - Func? deserializePayload, - IReadOnlyList indexAccessors, - IReadOnlyList 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; - SerializePayload = serializePayload; - DeserializePayload = deserializePayload; - IndexAccessors = indexAccessors ?? Array.Empty(); - Members = members ?? Array.Empty(); - } - - public Type DocumentType { get; } - - public string SchemaName { get; } - - public string SchemaVersion { get; } - - public bool IsGlobal { get; } - - public string? NameMember { get; } - - public Func? NameAccessor { get; } - - public Func? SerializePayload { get; } - - public Func? DeserializePayload { get; } - - public IReadOnlyList IndexAccessors { get; } - - public IReadOnlyList Members { get; } - } - - public sealed class CultGeneratedDocumentIndexAccessor - { - public CultGeneratedDocumentIndexAccessor(string alias, Func 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 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 LoadDefinitions() - { - return AppDomain.CurrentDomain.GetAssemblies() - .Where(assembly => !assembly.IsDynamic) - .SelectMany(LoadDefinitions); - } - - public static IEnumerable LoadDefinitions(Assembly assembly) - { - foreach (var attribute in assembly.GetCustomAttributes()) - { - 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(); - } - } -} diff --git a/src/GameCult.Caching/CultSchemaTypeNames.cs b/src/GameCult.Caching/CultSchemaTypeNames.cs new file mode 100644 index 00000000..ada0d594 --- /dev/null +++ b/src/GameCult.Caching/CultSchemaTypeNames.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Text; + +namespace GameCult.Caching +{ + 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(); + } + } +} diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj index 7bc41ccc..a34cdfd3 100644 --- a/src/GameCult.Caching/GameCult.Caching.csproj +++ b/src/GameCult.Caching/GameCult.Caching.csproj @@ -8,6 +8,5 @@ - \ No newline at end of file diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 7e51227b..e9edb63d 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -37,11 +37,9 @@ 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. +`CultDocumentRegistry` builds every document descriptor by reflection over +these attributes and MessagePack's `[Key]`/`[IgnoreMember]`; it is the only +descriptor authority. ## Main Concepts diff --git a/src/GameCult.Networking/GameCult.Networking.csproj b/src/GameCult.Networking/GameCult.Networking.csproj index 951cc1cc..0d63f94c 100644 --- a/src/GameCult.Networking/GameCult.Networking.csproj +++ b/src/GameCult.Networking/GameCult.Networking.csproj @@ -7,9 +7,6 @@ - diff --git a/tests/GameCult.Caching.ConsumerBuild/GameCult.Caching.ConsumerBuild.csproj b/tests/GameCult.Caching.ConsumerBuild/GameCult.Caching.ConsumerBuild.csproj index a05b8113..6401cb6c 100644 --- a/tests/GameCult.Caching.ConsumerBuild/GameCult.Caching.ConsumerBuild.csproj +++ b/tests/GameCult.Caching.ConsumerBuild/GameCult.Caching.ConsumerBuild.csproj @@ -4,13 +4,9 @@ net10.0 enable false - true - diff --git a/tests/GameCult.Caching.InteropPeer/GameCult.Caching.InteropPeer.csproj b/tests/GameCult.Caching.InteropPeer/GameCult.Caching.InteropPeer.csproj index 9a28830c..bd0f9ac7 100644 --- a/tests/GameCult.Caching.InteropPeer/GameCult.Caching.InteropPeer.csproj +++ b/tests/GameCult.Caching.InteropPeer/GameCult.Caching.InteropPeer.csproj @@ -8,9 +8,6 @@ - diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 332d4081..98ce654e 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -726,47 +726,30 @@ namespace GameCult.Caching.Tests } [Test] - public void GeneratedMetadataProvider_Emits_AttributedDocuments_And_References() + public void Registry_Describes_AttributedDocuments_And_References() { - var providers = typeof(NamedTestEntry).Assembly - .GetCustomAttributes(typeof(CultGeneratedDocumentMetadataProviderAttribute), false) - .Cast() - .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)); + var named = CultDocumentRegistry.Shared.GetRequired(); 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)); + var parentMember = CultDocumentRegistry.Shared.GetRequired().ToCatalogEntry().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 GeneratedMetadataProvider_Emits_Payload_Codecs_For_Plain_CultDocuments() + public void Plain_CultDocument_Payload_RoundTrips() { - var descriptor = CultDocumentRegistry.Shared.GetRequired(); var original = new NamedTestEntry { Name = "Teeth", Value = "slot-array" }; - Assert.That(descriptor.GeneratedPayloadSerializer, Is.Not.Null); - Assert.That(descriptor.GeneratedPayloadDeserializer, Is.Not.Null); - - var payload = descriptor.GeneratedPayloadSerializer!(original); - var roundTrip = (NamedTestEntry)descriptor.GeneratedPayloadDeserializer!(payload); + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(original, typeof(NamedTestEntry)); + var roundTrip = (NamedTestEntry)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(NamedTestEntry), payload); Assert.That(roundTrip.Name, Is.EqualTo("Teeth")); Assert.That(roundTrip.Value, Is.EqualTo("slot-array")); @@ -1036,9 +1019,10 @@ namespace GameCult.Caching.Tests "{\"schemaName\":\"tests.named_entry\",\"schemaVersion\":\"tests.named_entry.v1\",\"members\":[{\"slot\":0,\"name\":\"Name\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"Value\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}"; private const string NamedFixtureSchemaId = "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8"; private const string NamedFixtureContentHash = "sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e"; - private const string ReferenceFixtureSchemaId = "sha256:bd85064961cc74565fb73e3ccbc4217cfba4dc4869e365a08bea4f704739bd8f"; + private const string ReferenceFixtureSchemaId = "sha256:4ec0c52c7581c04777525af72f805b10705212ffd0138f3bff1eb4bcd1d0f23c"; [CultDocument("tests.named_entry", "tests.named_entry.v1")] + [MessagePackObject(AllowPrivate = true)] internal sealed class NamedTestEntry { [Key(0)] @@ -1050,6 +1034,7 @@ namespace GameCult.Caching.Tests } [CultDocument("tests.global_entry", "tests.global_entry.v1")] + [MessagePackObject(AllowPrivate = true)] [CultGlobal] internal sealed class GlobalTestEntry { @@ -1058,6 +1043,7 @@ namespace GameCult.Caching.Tests } [CultDocument("tests.reference_holder", "tests.reference_holder.v1")] + [MessagePackObject(AllowPrivate = true)] internal sealed class ReferenceHolderEntry { [Key(0)] @@ -1069,6 +1055,7 @@ namespace GameCult.Caching.Tests } [CultDocument("tests.named_entry", "tests.named_entry.v2")] + [MessagePackObject(AllowPrivate = true)] internal sealed class NamedTestEntryAdditive { [Key(0)] @@ -1083,6 +1070,7 @@ namespace GameCult.Caching.Tests } [CultDocument("tests.named_entry", "tests.named_entry.v3")] + [MessagePackObject(AllowPrivate = true)] internal sealed class NamedTestEntryRemoved { [Key(0)] @@ -1091,6 +1079,7 @@ namespace GameCult.Caching.Tests } [CultDocument("tests.named_entry", "tests.named_entry.v4")] + [MessagePackObject(AllowPrivate = true)] internal sealed class NamedTestEntryTypeMismatch { [Key(0)] @@ -1102,6 +1091,7 @@ namespace GameCult.Caching.Tests } [CultDocument("tests.alt_named_entry", "tests.alt_named_entry.v1")] + [MessagePackObject(AllowPrivate = true)] internal sealed class AlternateNamedTestEntry { [Key(0)] @@ -1110,6 +1100,7 @@ namespace GameCult.Caching.Tests } [CultDocument("tests.reference_holder", "tests.reference_holder.v2")] + [MessagePackObject(AllowPrivate = true)] internal sealed class ReferenceHolderRetargetedEntry { [Key(0)] diff --git a/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs b/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs index d980db3e..2e061f47 100644 --- a/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs +++ b/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs @@ -140,6 +140,9 @@ public sealed class CultDocumentRegistryTests type.SetCustomAttribute(new CustomAttributeBuilder( attributeConstructor, new object[] { schemaName, schemaVersion })); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(global::MessagePack.MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, + new object[] { false })); if (addStringField) { type.DefineField("Value", typeof(string), FieldAttributes.Public).SetCustomAttribute(new CustomAttributeBuilder( diff --git a/tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs b/tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs deleted file mode 100644 index 12d6e70d..00000000 --- a/tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs +++ /dev/null @@ -1,253 +0,0 @@ -#nullable enable -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using GameCult.Caching; -using GameCult.Caching.MessagePack; -using GameCult.Caching.MessagePack.Generator; -using MessagePack; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using NUnit.Framework; - -namespace GameCult.Caching.Tests -{ - // Every shape compiles through the generator; its generated descriptor and codec must equal the reflective - // descriptor and MessagePack's own dynamic payload, or both paths must reject it with the same message. - public class DocumentShapeSweepTests - { - // Color sits in a namespace: global-namespace member types name differently in the generator and CultSchemaTypeNames. - private const string Prelude = "using System.Collections.Generic;\nusing GameCult.Caching;\nusing MessagePack;\nusing Shapes;\nnamespace Shapes { public enum Color { Red, Blue } }\n"; - - [Test] - public void FlatKeyedDocument() => Accept(@" -[CultDocument(""sweep.plain"", ""sweep.plain.v1"")] -[MessagePackObject] -public class Plain -{ - [Key(0)] [CultName] public string Name { get; set; } = """"; - [Key(1)] public int? Maybe; - [Key(2)] public Color Tint { get; set; } - [Key(3)] public List Tags = new List(); - [Key(4)] public Dictionary Counts { get; set; } = new Dictionary(); - [IgnoreMember] public int Skip { get; set; } -} -public static class Samples -{ - public static object Plain() => new Plain { Name = ""p"", Maybe = 7, Tint = Color.Blue, Tags = { ""a"" }, Counts = { [""x""] = 1 }, Skip = 9 }; -}", "Plain"); - - [Test] - public void DerivedDocument() => Accept(@" -[CultDocument(""sweep.gear"", ""sweep.gear.v1"")] -[MessagePackObject] -public class Gear { [Key(0)] public string Name { get; set; } = """"; } -[CultDocument(""sweep.weapon"", ""sweep.weapon.v1"")] -[MessagePackObject] -public sealed class Weapon : Gear { [Key(1)] public int Damage { get; set; } } -public static class Samples -{ - public static object Gear() => new Gear { Name = ""helm"" }; - public static object Weapon() => new Weapon { Name = ""lance"", Damage = 4 }; -}", "Gear", "Weapon"); - - [Test] - public void NameAndIndexOnABaseSharedByTwoDocuments() => Accept(@" -[CultDocument(""sweep.gear"", ""sweep.gear.v1"")] -[MessagePackObject] -public class Gear -{ - [Key(0)] [CultName] public string Name { get; set; } = """"; - [Key(1)] [CultIndex(""slot"")] public string Slot { get; set; } = """"; -} -[CultDocument(""sweep.weapon"", ""sweep.weapon.v1"")] -[MessagePackObject] -public sealed class Weapon : Gear { [Key(2)] public int Damage { get; set; } } -public static class Samples -{ - public static object Gear() => new Gear { Name = ""helm"", Slot = ""head"" }; - public static object Weapon() => new Weapon { Name = ""lance"", Slot = ""hand"", Damage = 4 }; -}", "Gear", "Weapon"); - - [Test] - public void OverrideKeepsBaseKey() - { - var payloads = Accept(@" -[MessagePackObject] -public class OvrSameBase { [Key(0)] public virtual string Name { get; set; } = """"; [Key(2)] public virtual int Z { get; set; } } -[CultDocument(""sweep.ovr_same"", ""sweep.ovr_same.v1"")] -[MessagePackObject] -public sealed class OvrSame : OvrSameBase -{ - [Key(0)] public override string Name { get; set; } = """"; - public override int Z { get; set; } - [Key(1)] public int X { get; set; } -} -public static class Samples -{ - public static object OvrSame() => new OvrSame { Name = ""n"", X = 3, Z = 5 }; -}", "OvrSame"); - - // Captured from MessagePack's DynamicObjectResolver: [Name, X, Z]. - Assert.That(Convert.ToHexString(payloads["OvrSame"]), Is.EqualTo("93A16E0305")); - } - - // MessagePack's DynamicObjectResolver reads the base declaration: it throws "key is duplicated" for this shape. - [Test] - public void OverrideReKeyed() => Reject(@" -[MessagePackObject] -public class OvrDiffBase { [Key(0)] public virtual string Name { get; set; } = """"; } -[CultDocument(""sweep.ovr_diff"", ""sweep.ovr_diff.v1"")] -[MessagePackObject] -public sealed class OvrDiff : OvrDiffBase -{ - [Key(2)] public override string Name { get; set; } = """"; - [Key(1)] public int X { get; set; } - [Key(0)] public int Y { get; set; } -}", - "OvrDiff", - "Cult document OvrDiff member OvrDiff.Name overrides OvrDiffBase.Name with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them."); - - // MessagePack's DynamicObjectResolver keeps the base [Key(1)] and serializes Name despite the override's [IgnoreMember]. - [Test] - public void OverrideIgnored() => Reject(@" -[MessagePackObject] -public class OvrIgnoreBase { [Key(1)] public virtual string Name { get; set; } = """"; } -[CultDocument(""sweep.ovr_ignore"", ""sweep.ovr_ignore.v1"")] -[MessagePackObject] -public sealed class OvrIgnore : OvrIgnoreBase -{ - [IgnoreMember] public override string Name { get; set; } = """"; - [Key(0)] public int X { get; set; } -}", - "OvrIgnore", - "Cult document OvrIgnore member OvrIgnore.Name overrides OvrIgnoreBase.Name with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them."); - - [Test] - public void NewHiddenField() => Reject(@" -public class NewFieldBase { [Key(0)] public string Name = ""base""; } -[CultDocument(""sweep.new_field"", ""sweep.new_field.v1"")] -[MessagePackObject] -public sealed class NewField : NewFieldBase { [Key(0)] public new string Name = """"; [Key(1)] public int X; }", - "NewField", - "Cult document NewField members NewField.Name and NewFieldBase.Name share [Key(0)]; every persisted member needs a distinct [Key(n)]."); - - [Test] - public void NewHiddenProperty() => Reject(@" -public class NewPropBase { [Key(0)] public string Name { get; set; } = ""base""; } -[CultDocument(""sweep.new_prop"", ""sweep.new_prop.v1"")] -[MessagePackObject] -public sealed class NewProp : NewPropBase { [Key(0)] public new string Name { get; set; } = """"; [Key(1)] public int X { get; set; } }", - "NewProp", - "Cult document NewProp members NewProp.Name and NewPropBase.Name share [Key(0)]; every persisted member needs a distinct [Key(n)]."); - - [Test] - public void DuplicateSlot() => Reject(@" -[CultDocument(""sweep.dup"", ""sweep.dup.v1"")] -[MessagePackObject] -public sealed class Dup { [Key(0)] public int B; [Key(0)] public int A; }", - "Dup", - "Cult document Dup members Dup.A and Dup.B share [Key(0)]; every persisted member needs a distinct [Key(n)]."); - - [Test] - public void InheritedPrivateSetter() => Reject(@" -public class PrivSetBase { [Key(0)] public string Name { get; private set; } = ""fixed""; } -[CultDocument(""sweep.privset"", ""sweep.privset.v1"")] -[MessagePackObject] -public sealed class PrivSetDerived : PrivSetBase { [Key(1)] public int X { get; set; } }", - "PrivSetDerived", - "Cult document PrivSetDerived member Name is not writable; a persisted member needs a non-readonly field or a public or internal set accessor."); - - [Test] - public void StringKey() => Reject(@" -[CultDocument(""sweep.string_key"", ""sweep.string_key.v1"")] -[MessagePackObject] -public sealed class StringKeyed { [Key(""name"")] public string Name { get; set; } = """"; }", - "StringKeyed", - "Cult document StringKeyed member Name has a string [Key]; string keys are not supported; use integer [Key(n)]."); - - private static Dictionary Accept(string source, params string[] documentNames) - { - var assembly = Compile(source, out var generatorErrors); - Assert.That(generatorErrors, Is.Empty); - var samples = assembly.GetType("Samples")!; - var payloads = new Dictionary(); - foreach (var name in documentNames) - { - var type = assembly.GetType(name)!; - var sample = samples.GetMethod(name)!.Invoke(null, null)!; - var generated = CultDocumentRegistry.ForTypes(new[] { type }).GetRequired(type); - var reflective = CultDocumentRegistry.BuildDescriptor(type); - Assert.That(generated.GeneratedPayloadSerializer, Is.Not.Null, $"{name}: no generated codec"); - Assert.That(generated.GeneratedPayloadDeserializer, Is.Not.Null, $"{name}: no generated decoder"); - - var options = CultDocumentMessagePackSerialization.OptionsFor(assembly); - var dynamicBytes = MessagePackSerializer.Serialize(type, sample, options); - var generatedBytes = generated.GeneratedPayloadSerializer!(sample); - var decoded = generated.GeneratedPayloadDeserializer!(generatedBytes); - Assert.Multiple(() => - { - Assert.That(generated.SchemaId, Is.EqualTo(reflective.SchemaId), $"{name}: schema id"); - Assert.That(generated.CanonicalSchemaJson, Is.EqualTo(reflective.CanonicalSchemaJson), $"{name}: schema json"); - Assert.That(Catalog(generated), Is.EqualTo(Catalog(reflective)), $"{name}: catalog members"); - Assert.That(generated.NameMember, Is.EqualTo(reflective.NameMember), $"{name}: name member"); - Assert.That(generated.NameAccessor?.Invoke(sample), Is.EqualTo(reflective.NameAccessor?.Invoke(sample)), $"{name}: name value"); - Assert.That(Indexes(generated, sample), Is.EqualTo(Indexes(reflective, sample)), $"{name}: index values"); - Assert.That(Convert.ToHexString(generatedBytes), Is.EqualTo(Convert.ToHexString(dynamicBytes)), $"{name}: payload"); - Assert.That(Convert.ToHexString(generated.GeneratedPayloadSerializer(decoded)), Is.EqualTo(Convert.ToHexString(generatedBytes)), $"{name}: round trip"); - Assert.That(Convert.ToHexString(generated.GeneratedPayloadSerializer(MessagePackSerializer.Deserialize(type, dynamicBytes, options)!)), - Is.EqualTo(Convert.ToHexString(dynamicBytes)), $"{name}: dynamic round trip"); - }); - payloads[name] = generatedBytes; - } - - return payloads; - } - - private static void Reject(string source, string documentName, string expected) - { - var assembly = Compile(source, out var generatorErrors); - var reflective = Assert.Throws(() => CultDocumentRegistry.BuildDescriptor(assembly.GetType(documentName)!))!; - Assert.Multiple(() => - { - Assert.That(generatorErrors.FirstOrDefault(), Is.EqualTo(expected), "generator"); - Assert.That(reflective.Message, Is.EqualTo(expected), "registry"); - }); - } - - private static string[] Catalog(CultDocumentDescriptor descriptor) => - descriptor.ToCatalogEntry().Members - .Select(member => $"{member.Slot}:{member.MemberName}:{member.TypeName}:{member.IsReference}:{member.IsMany}:{member.TargetSchemaName}:{member.IsName}:{member.IndexAlias}") - .ToArray(); - - private static string[] Indexes(CultDocumentDescriptor descriptor, object sample) => - descriptor.IndexAccessors.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value(sample)}").ToArray(); - - private static Assembly Compile(string source, out string[] generatorErrors) - { - var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) - .Split(Path.PathSeparator) - .Append(typeof(CultDocumentAttribute).Assembly.Location) - .Append(typeof(CultDocumentMessagePackSerialization).Assembly.Location) - .Append(typeof(KeyAttribute).Assembly.Location) - .Append(typeof(MessagePackSerializer).Assembly.Location) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(path => MetadataReference.CreateFromFile(path)); - var compilation = CSharpCompilation.Create( - "Sweep_" + Guid.NewGuid().ToString("N"), - new[] { CSharpSyntaxTree.ParseText(Prelude + source) }, - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); - CSharpGeneratorDriver.Create(new CultDocumentMessagePackGenerator()) - .RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); - generatorErrors = diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Select(diagnostic => diagnostic.GetMessage()).ToArray(); - - using var stream = new MemoryStream(); - var emitted = output.Emit(stream); - Assert.That(emitted.Diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Select(diagnostic => diagnostic.ToString()), Is.Empty, "compile"); - return Assembly.Load(stream.ToArray()); - } - } -} diff --git a/tests/GameCult.Caching.Tests/DocumentShapeTests.cs b/tests/GameCult.Caching.Tests/DocumentShapeTests.cs new file mode 100644 index 00000000..dd12b640 --- /dev/null +++ b/tests/GameCult.Caching.Tests/DocumentShapeTests.cs @@ -0,0 +1,291 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // Accepted shapes are ordinary test types. Rejected shapes are emitted into a dynamic assembly, which registry discovery skips, + // so a refused shape cannot break a registry built from the loaded assemblies. + public class DocumentShapeTests + { + private const string DivergentOverride = "overrides {0} with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them."; + + private static readonly ModuleBuilder Rejected = AssemblyBuilder + .DefineDynamicAssembly(new AssemblyName("RejectedShapes"), AssemblyBuilderAccess.Run) + .DefineDynamicModule("RejectedShapes"); + + [Test] + public void FlatKeyedDocument() + { + Accept(new Plain { Name = "p", Maybe = 7, Tint = Color.Blue, Tags = { "a" }, Counts = { ["x"] = 1 }, Skip = 9 }); + var members = CultDocumentRegistry.ForTypes(new[] { typeof(Plain) }).GetRequired().ToCatalogEntry().Members; + Assert.That(members.Select(member => $"{member.Slot}:{member.MemberName}"), Is.EqualTo(new[] { "0:Name", "1:Maybe", "2:Tint", "3:Tags", "4:Counts" })); + } + + [Test] + public void NameAndIndexOnABaseSharedByTwoDocuments() + { + Accept(new Gear { Name = "helm", Slot = "head" }); + Accept(new Weapon { Name = "lance", Slot = "hand", Damage = 4 }); + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(Gear), typeof(Weapon) }); + foreach (var type in new[] { typeof(Gear), typeof(Weapon) }) + { + var descriptor = registry.GetRequired(type); + var members = descriptor.ToCatalogEntry().Members; + Assert.That(descriptor.NameMember, Is.EqualTo("Name"), type.Name); + Assert.That(members.Single(member => member.MemberName == "Slot").IndexAlias, Is.EqualTo("slot"), type.Name); + } + + Assert.That(registry.GetRequired().ToCatalogEntry().Members.Select(member => member.Slot), Is.EqualTo(new[] { 0, 1, 2 })); + } + + [Test] + public void OverrideKeepsBaseKey() + { + var payload = Accept(new OvrSame { Name = "n", X = 3, Z = 5 }); + + // Captured from MessagePack's DynamicObjectResolver: [Name, X, Z]. + Assert.That(Convert.ToHexString(payload), Is.EqualTo("93A16E0305")); + } + + [Test] + public void NotAMessagePackObject() + { + var type = Rejected.DefineType("NoObject", TypeAttributes.Public); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, new object[] { "shape.no_object", "shape.no_object.v1" })); + Field(type, "Name", typeof(string), Key(0)); + Reject(type.CreateType()!, "Cult document NoObject is not a MessagePack object; add [MessagePackObject] so MessagePack serializes its [Key(n)] members."); + } + + [Test] + public void NonPublicWithoutAllowPrivate() + { + var type = Rejected.DefineType("Hidden", TypeAttributes.NotPublic); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, new object[] { "shape.hidden", "shape.hidden.v1" })); + type.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false })); + Field(type, "Name", typeof(string), Key(0)); + Reject(type.CreateType()!, "Cult document Hidden is not public; MessagePack serializes a non-public type only with [MessagePackObject(AllowPrivate = true)], so make it public or set AllowPrivate."); + } + + [Test] + public void AllowPrivateWithUnmarkedNonPublicMember() + { + var type = Rejected.DefineType("PrivateField", TypeAttributes.NotPublic); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, new object[] { "shape.private_field", "shape.private_field.v1" })); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false }, + new[] { typeof(MessagePackObjectAttribute).GetProperty(nameof(MessagePackObjectAttribute.AllowPrivate))! }, new object[] { true })); + Field(type, "Name", typeof(string), Key(0)); + type.DefineField("_cache", typeof(int), FieldAttributes.Private); + Reject(type.CreateType()!, "Cult document PrivateField member PrivateField._cache is non-public; [MessagePackObject(AllowPrivate = true)] makes MessagePack read it, so mark it [IgnoreMember]."); + } + + [Test] + public void NonPublicWithAllowPrivateRoundTrips() + { + var payload = Accept(new InternalNote { Name = "n", Count = 2 }); + Assert.That(Convert.ToHexString(payload), Is.EqualTo("92A16E02")); + } + + [Test] + public void UnkeyedMember() + { + var type = Document("Unkeyed"); + Field(type, "Name", typeof(string), Key(0)); + Field(type, "Count", typeof(int)); + Reject(type.CreateType()!, "Cult document Unkeyed member Count has no [Key]; every persisted member of a [CultDocument] type needs an explicit [Key(n)]."); + } + + // MessagePack's DynamicObjectResolver reads the base declaration: it throws "key is duplicated" for this shape. + [Test] + public void OverrideReKeyed() + { + var baseType = Rejected.DefineType("OvrDiffBase", TypeAttributes.Public); + Property(baseType, "Name", MethodAttributes.Virtual, MethodAttributes.Public, Key(0)); + var type = Document("OvrDiff", baseType.CreateType()); + Property(type, "Name", MethodAttributes.Virtual, MethodAttributes.Public, Key(2)); + Property(type, "X", 0, MethodAttributes.Public, Key(1)); + Property(type, "Y", 0, MethodAttributes.Public, Key(0)); + Reject(type.CreateType()!, "Cult document OvrDiff member OvrDiff.Name " + string.Format(DivergentOverride, "OvrDiffBase.Name")); + } + + // MessagePack's DynamicObjectResolver keeps the base [Key(1)] and serializes Name despite the override's [IgnoreMember]. + [Test] + public void OverrideIgnored() + { + var baseType = Rejected.DefineType("OvrIgnoreBase", TypeAttributes.Public); + Property(baseType, "Name", MethodAttributes.Virtual, MethodAttributes.Public, Key(1)); + var type = Document("OvrIgnore", baseType.CreateType()); + Property(type, "Name", MethodAttributes.Virtual, MethodAttributes.Public, new CustomAttributeBuilder(typeof(IgnoreMemberAttribute).GetConstructor(Type.EmptyTypes)!, Array.Empty())); + Property(type, "X", 0, MethodAttributes.Public, Key(0)); + Reject(type.CreateType()!, "Cult document OvrIgnore member OvrIgnore.Name " + string.Format(DivergentOverride, "OvrIgnoreBase.Name")); + } + + [Test] + public void NewHiddenField() + { + var baseType = Rejected.DefineType("NewFieldBase", TypeAttributes.Public); + Field(baseType, "Name", typeof(string), Key(0)); + var type = Document("NewField", baseType.CreateType()); + Field(type, "Name", typeof(string), Key(0)); + Field(type, "X", typeof(int), Key(1)); + Reject(type.CreateType()!, "Cult document NewField members NewField.Name and NewFieldBase.Name share [Key(0)]; every persisted member needs a distinct [Key(n)]."); + } + + [Test] + public void NewHiddenProperty() + { + var baseType = Rejected.DefineType("NewPropBase", TypeAttributes.Public); + Property(baseType, "Name", 0, MethodAttributes.Public, Key(0)); + var type = Document("NewProp", baseType.CreateType()); + Property(type, "Name", 0, MethodAttributes.Public, Key(0)); + Property(type, "X", 0, MethodAttributes.Public, Key(1)); + Reject(type.CreateType()!, "Cult document NewProp members NewProp.Name and NewPropBase.Name share [Key(0)]; every persisted member needs a distinct [Key(n)]."); + } + + [Test] + public void DuplicateSlot() + { + var type = Document("Dup"); + Field(type, "B", typeof(int), Key(0)); + Field(type, "A", typeof(int), Key(0)); + Reject(type.CreateType()!, "Cult document Dup members Dup.A and Dup.B share [Key(0)]; every persisted member needs a distinct [Key(n)]."); + } + + [Test] + public void InheritedPrivateSetter() + { + var baseType = Rejected.DefineType("PrivSetBase", TypeAttributes.Public); + Property(baseType, "Name", 0, MethodAttributes.Private, Key(0)); + var type = Document("PrivSetDerived", baseType.CreateType()); + Property(type, "X", 0, MethodAttributes.Public, Key(1)); + Reject(type.CreateType()!, "Cult document PrivSetDerived member Name is not writable; a persisted member needs a non-readonly field or a public or internal set accessor."); + } + + [Test] + public void StringKey() + { + var type = Document("StringKeyed"); + Property(type, "Name", 0, MethodAttributes.Public, new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(string) })!, new object[] { "name" })); + Reject(type.CreateType()!, "Cult document StringKeyed member Name has a string [Key]; string keys are not supported; use integer [Key(n)]."); + } + + private static byte[] Accept(object sample) + { + var type = sample.GetType(); + var registry = CultDocumentRegistry.ForTypes(new[] { type }); + var expected = MessagePackSerializer.Serialize(type, sample, CultDocumentMessagePackSerialization.OptionsFor(type.Assembly)); + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(sample, type, registry); + var decoded = CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, registry); + Assert.That(Convert.ToHexString(payload), Is.EqualTo(Convert.ToHexString(expected)), $"{type.Name}: payload"); + Assert.That(Convert.ToHexString(CultDocumentMessagePackSerialization.SerializeUntyped(decoded, type, registry)), Is.EqualTo(Convert.ToHexString(payload)), $"{type.Name}: round trip"); + return payload; + } + + private static void Reject(Type type, string expected) => + Assert.That(Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type }))!.Message, Is.EqualTo(expected)); + + private static CustomAttributeBuilder Key(int slot) => + new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot }); + + private static TypeBuilder Document(string name, Type? parent = null) + { + var type = Rejected.DefineType(name, TypeAttributes.Public, parent); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, + new object[] { "shape." + name, "shape." + name + ".v1" })); + type.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false })); + return type; + } + + private static void Field(TypeBuilder type, string name, Type fieldType, params CustomAttributeBuilder[] attributes) + { + var field = type.DefineField(name, fieldType, FieldAttributes.Public); + foreach (var attribute in attributes) field.SetCustomAttribute(attribute); + } + + private static void Property(TypeBuilder type, string name, MethodAttributes shape, MethodAttributes setterAccess, params CustomAttributeBuilder[] attributes) + { + var propertyType = typeof(string); + var backing = type.DefineField("_" + name, propertyType, FieldAttributes.Private); + var getter = type.DefineMethod("get_" + name, MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig | shape, propertyType, Type.EmptyTypes); + var il = getter.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, backing); + il.Emit(OpCodes.Ret); + var setter = type.DefineMethod("set_" + name, setterAccess | MethodAttributes.SpecialName | MethodAttributes.HideBySig | shape, null, new[] { propertyType }); + il = setter.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldarg_1); + il.Emit(OpCodes.Stfld, backing); + il.Emit(OpCodes.Ret); + var property = type.DefineProperty(name, PropertyAttributes.None, propertyType, null); + property.SetGetMethod(getter); + property.SetSetMethod(setter); + foreach (var attribute in attributes) property.SetCustomAttribute(attribute); + } + + public enum Color { Red, Blue } + + [CultDocument("shape.plain", "shape.plain.v1")] + [MessagePackObject] + public class Plain + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public int? Maybe; + [Key(2)] public Color Tint { get; set; } + [Key(3)] public List Tags = new(); + [Key(4)] public Dictionary Counts { get; set; } = new(); + [IgnoreMember] public int Skip { get; set; } + } + + [CultDocument("shape.gear", "shape.gear.v1")] + [MessagePackObject] + public class Gear + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] [CultIndex("slot")] public string Slot { get; set; } = ""; + } + + [CultDocument("shape.weapon", "shape.weapon.v1")] + [MessagePackObject] + public sealed class Weapon : Gear + { + [Key(2)] public int Damage { get; set; } + } + + [CultDocument("shape.internal_note", "shape.internal_note.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class InternalNote + { + [Key(0)] public string Name = ""; + [Key(1)] public int Count; + } + + [MessagePackObject] + public class OvrSameBase + { + [Key(0)] public virtual string Name { get; set; } = ""; + [Key(2)] public virtual int Z { get; set; } + } + + [CultDocument("shape.ovr_same", "shape.ovr_same.v1")] + [MessagePackObject] + public sealed class OvrSame : OvrSameBase + { + [Key(0)] public override string Name { get; set; } = ""; + public override int Z { get; set; } + [Key(1)] public int X { get; set; } + } + } +} diff --git a/tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj b/tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj index 31d33a30..d98c076f 100644 --- a/tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj +++ b/tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj @@ -4,18 +4,11 @@ net10.0 false GameCult.Caching.Tests - - $(NoWarn);RS1036;RS1041 - - - diff --git a/tests/GameCult.Caching.Tests/README.md b/tests/GameCult.Caching.Tests/README.md index 8f8d83c1..c8361303 100644 --- a/tests/GameCult.Caching.Tests/README.md +++ b/tests/GameCult.Caching.Tests/README.md @@ -19,8 +19,8 @@ The tests currently cover: - cache lookups (`Get`, `GetAll`, runtime-type upserts) - `CultCache.Commit` batches on a directory store: visibility after the store commits, abandoned stages, sealed batches, observers committing again -- explicit Cult document payload codecs, generated metadata, canonical schema - fixtures, and schema drift reports +- Cult document payloads, registry shape rejections, canonical schema fixtures, + and schema drift reports - hand-written MessagePack snapshot and record serialization - `StoreRoutingTests`: the store-composition contract (routing, attachment is hydration, read-only stores, global singletons, assignable lookups, batches, diff --git a/tests/GameCult.Caching.Tests/SerializationOptionsTests.cs b/tests/GameCult.Caching.Tests/SerializationOptionsTests.cs index 48aabc50..7127f574 100644 --- a/tests/GameCult.Caching.Tests/SerializationOptionsTests.cs +++ b/tests/GameCult.Caching.Tests/SerializationOptionsTests.cs @@ -4,12 +4,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Reflection.Emit; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; +using MessagePack.Formatters; using NUnit.Framework; namespace GameCult.Caching.Tests @@ -76,26 +76,17 @@ namespace GameCult.Caching.Tests { var registry = CultDocumentRegistry.ForTypes(new[] { typeof(PairHolder) }); var holder = new PairHolder { Name = "pair", Value = new Pair { A = 1.5f, B = -2f } }; - Assert.That(registry.GetRequired().GeneratedPayloadSerializer, Is.Not.Null, "no generated codec"); - var generated = CultDocumentMessagePackSerialization.SerializeUntyped(holder, typeof(PairHolder), registry); - // SerializeUntyped's reflective fallback, which a document without a generated codec takes. - var reflective = MessagePackSerializer.Serialize( - typeof(PairHolder), holder, CultDocumentMessagePackSerialization.OptionsFor(typeof(PairHolder).Assembly)); + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(holder, typeof(PairHolder), registry); - Assert.That(reflective, Is.EqualTo(generated)); - var reader = new MessagePackReader(generated); + var reader = new MessagePackReader(payload); Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); reader.Skip(); Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); Assert.That(reader.ReadSingle(), Is.EqualTo(1.5f)); Assert.That(reader.ReadSingle(), Is.EqualTo(-2f)); - - var fromGenerated = (PairHolder)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(PairHolder), generated, registry); - var fromReflective = (PairHolder)MessagePackSerializer.Deserialize( - typeof(PairHolder), reflective, CultDocumentMessagePackSerialization.OptionsFor(typeof(PairHolder).Assembly))!; - Assert.That(fromGenerated.Value, Is.EqualTo(holder.Value)); - Assert.That(fromReflective.Value, Is.EqualTo(holder.Value)); + var decoded = (PairHolder)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(PairHolder), payload, registry); + Assert.That(decoded.Value, Is.EqualTo(holder.Value)); } [Test] @@ -113,52 +104,25 @@ namespace GameCult.Caching.Tests [Test] public void GenericDocumentUsesItsOwnAssemblyResolvers() { - const string source = @" -using GameCult.Caching; -using GameCult.Caching.MessagePack; -using GameCult.Caching.Tests; -using MessagePack; -using MessagePack.Formatters; -[assembly: CultCacheFormatterResolver(typeof(ReversedPairResolver))] -[CultDocument(""tests.generic_holder"", ""tests.generic_holder.v1"")] -[MessagePackObject] -public sealed class GenericHolder { [Key(0)] public T Value; } -public sealed class ReversedPairResolver : IFormatterResolver -{ - public static readonly IFormatterResolver Instance = new ReversedPairResolver(); - public IMessagePackFormatter GetFormatter() => typeof(T) == typeof(Pair) ? (IMessagePackFormatter)(object)new ReversedPairFormatter() : null; -} -public sealed class ReversedPairFormatter : IMessagePackFormatter -{ - public void Serialize(ref MessagePackWriter writer, Pair value, MessagePackSerializerOptions options) { writer.WriteArrayHeader(2); writer.Write(value.B); writer.Write(value.A); } - public Pair Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.ReadArrayHeader(); var b = reader.ReadSingle(); return new Pair { A = reader.ReadSingle(), B = b }; } -}"; - var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) - .Split(Path.PathSeparator) - .Append(typeof(CultDocumentAttribute).Assembly.Location) - .Append(typeof(CultDocumentMessagePackSerialization).Assembly.Location) - .Append(typeof(KeyAttribute).Assembly.Location) - .Append(typeof(MessagePackSerializer).Assembly.Location) - .Append(typeof(Pair).Assembly.Location) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(path => MetadataReference.CreateFromFile(path)); - var compilation = CSharpCompilation.Create( - "GenericHolders_" + Guid.NewGuid().ToString("N"), - new[] { CSharpSyntaxTree.ParseText(source) }, - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - using var stream = new MemoryStream(); - var emitted = compilation.Emit(stream); - Assert.That(emitted.Diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Select(diagnostic => diagnostic.ToString()), Is.Empty); - var assembly = Assembly.Load(stream.ToArray()); - var type = assembly.GetType("GenericHolder`1")!.MakeGenericType(typeof(Pair)); + var assembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("GenericHolders"), AssemblyBuilderAccess.Run, new[] + { + new CustomAttributeBuilder(typeof(CultCacheFormatterResolverAttribute).GetConstructor(new[] { typeof(Type) })!, new object[] { typeof(ReversedPairResolver) }) + }); + var builder = assembly.DefineDynamicModule("GenericHolders").DefineType("GenericHolder`1", TypeAttributes.Public | TypeAttributes.Sealed); + var parameter = builder.DefineGenericParameters("T")[0]; + builder.SetCustomAttribute(new CustomAttributeBuilder( + typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, new object[] { "tests.generic_holder", "tests.generic_holder.v1" })); + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false })); + builder.DefineField("Value", parameter, FieldAttributes.Public) + .SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { 0 })); + var type = builder.CreateType()!.MakeGenericType(typeof(Pair)); var registry = CultDocumentRegistry.ForTypes(new[] { type }); var holder = Activator.CreateInstance(type)!; type.GetField("Value")!.SetValue(holder, new Pair { A = 1.5f, B = -2f }); var payload = CultDocumentMessagePackSerialization.SerializeUntyped(holder, type, registry); - Assert.That(type.Assembly, Is.SameAs(assembly)); + Assert.That(type.Assembly, Is.Not.SameAs(typeof(Pair).Assembly)); var reader = new MessagePackReader(payload); Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1)); Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); @@ -168,6 +132,18 @@ public sealed class ReversedPairFormatter : IMessagePackFormatter Assert.That(type.GetField("Value")!.GetValue(decoded), Is.EqualTo(new Pair { A = 1.5f, B = -2f })); } + public sealed class ReversedPairResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new ReversedPairResolver(); + public IMessagePackFormatter? GetFormatter() => typeof(T) == typeof(Pair) ? (IMessagePackFormatter)(object)new ReversedPairFormatter() : null; + } + + public sealed class ReversedPairFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, Pair value, MessagePackSerializerOptions options) { writer.WriteArrayHeader(2); writer.Write(value.B); writer.Write(value.A); } + public Pair Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.ReadArrayHeader(); var b = reader.ReadSingle(); return new Pair { A = reader.ReadSingle(), B = b }; } + } + [CultDocument("tests.pair_holder", "tests.pair_holder.v1")] [MessagePackObject] public sealed class PairHolder @@ -177,6 +153,7 @@ public sealed class ReversedPairFormatter : IMessagePackFormatter } [CultDocument("cultcache.interop-note", "cultcache.interop_note.v1")] + [MessagePackObject] public sealed class OptionsInteropNote { [Key(0)] public string SchemaVersion { get; set; } = "cultcache.interop_note.v1"; @@ -188,6 +165,7 @@ public sealed class ReversedPairFormatter : IMessagePackFormatter } [CultDocument("tests.ref_keyed_holder", "tests.ref_keyed_holder.v1")] + [MessagePackObject] public sealed class RefKeyedHolder { [Key(0)] [CultName] public string Name = string.Empty; diff --git a/tests/GameCult.Caching.Tests/UnkeyedDocumentMemberTests.cs b/tests/GameCult.Caching.Tests/UnkeyedDocumentMemberTests.cs deleted file mode 100644 index b0fb5b3d..00000000 --- a/tests/GameCult.Caching.Tests/UnkeyedDocumentMemberTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -#nullable enable -using System; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Reflection.Emit; -using GameCult.Caching; -using GameCult.Caching.MessagePack.Generator; -using MessagePack; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using NUnit.Framework; - -namespace GameCult.Caching.Tests -{ - public class UnkeyedDocumentMemberTests - { - [Test] - public void UnkeyedDocumentMemberIsRejected() - { - var reflective = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { EmitUnkeyedDocument() }))!; - - const string source = @" -using GameCult.Caching; -using MessagePack; -[CultDocument(""tests.unkeyed"", ""tests.unkeyed.v1"")] -public class UnkeyedDocument -{ - [Key(0)] public string Name = string.Empty; - public int Count; -}"; - var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) - .Split(Path.PathSeparator) - .Append(typeof(CultDocumentAttribute).Assembly.Location) - .Append(typeof(KeyAttribute).Assembly.Location) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(path => MetadataReference.CreateFromFile(path)); - var compilation = CSharpCompilation.Create( - "UnkeyedConsumer", - new[] { CSharpSyntaxTree.ParseText(source) }, - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - CSharpGeneratorDriver.Create(new CultDocumentMessagePackGenerator()) - .RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); - - var generated = diagnostics.Single(diagnostic => diagnostic.Id == "GCC001"); - Assert.That(generated.Severity, Is.EqualTo(DiagnosticSeverity.Error)); - Assert.That(generated.GetMessage(), Is.EqualTo(reflective.Message)); - Assert.That(reflective.Message, Does.Contain("UnkeyedDocument member Count has no [Key]")); - } - - private static Type EmitUnkeyedDocument() - { - var module = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("UnkeyedDocuments"), AssemblyBuilderAccess.Run) - .DefineDynamicModule("UnkeyedDocuments"); - var type = module.DefineType("UnkeyedDocument", TypeAttributes.Public | TypeAttributes.Class); - type.SetCustomAttribute(new CustomAttributeBuilder( - typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, - new object[] { "tests.unkeyed", "tests.unkeyed.v1" })); - type.DefineField("Name", typeof(string), FieldAttributes.Public) - .SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { 0 })); - type.DefineField("Count", typeof(int), FieldAttributes.Public); - return type.CreateType()!; - } - } -} diff --git a/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj b/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj index 04736cd4..2a782a72 100644 --- a/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj +++ b/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj @@ -8,9 +8,6 @@ -