From 96ee5fdce3a29160b50fdcad34fd0238efd45e1c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 23:38:08 +0200 Subject: [PATCH] Delete the CultCache MessagePack source generator The reflective CultDocumentRegistry is now the only descriptor authority, and payloads always go through MessagePackSerializer with OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed, Unity always ran the reflective path, and no benchmark showed a gain, while the generator duplicated the registry's shape rules. Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution, project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata (provider attribute, interface, definitions, loader), the generated descriptor and codec branches in CultCache and CultDocumentMessagePackSerialization, DocumentShapeSweepTests and the generator half of the unkeyed test, the Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo. The registry rejects, with a message naming the fix, what MessagePackSerializer cannot serialize and what the generator used to paper over: a document without [MessagePackObject], a non-public document without AllowPrivate = true, and a non-public member of an AllowPrivate document that is not [IgnoreMember]. DocumentShapeTests pins every rejection message and the accepted shapes' round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin). Rejected shapes are emitted into a dynamic assembly, which registry discovery skips. Nested member type names use reflection's Outer+Inner form, not the generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned (bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj excluded) found no member type name naming a nested type in either form. Co-Authored-By: Claude Opus 5 --- CultLib.sln | 30 - README.md | 6 - scripts/pack-nuget.ps1 | 2 - .../Class1.cs | 11 - ...eCult.Caching.MessagePack.Analyzers.csproj | 33 - .../README.md | 25 - .../CultDocumentMessagePackGenerator.cs | 716 ------------------ ...eCult.Caching.MessagePack.Generator.csproj | 16 - .../README.md | 56 -- .../CultDocumentMessagePackSerialization.cs | 17 +- .../GameCult.Caching.MessagePack.csproj | 3 - src/GameCult.Caching.MessagePack/README.md | 14 +- .../cultcache-schema-compatibility.md | 10 +- .../Contracts/cultcache-store-composition.md | 3 +- src/GameCult.Caching/CultCache.cs | 103 +-- .../CultGeneratedDocumentMetadata.cs | 215 ------ src/GameCult.Caching/CultSchemaTypeNames.cs | 57 ++ src/GameCult.Caching/GameCult.Caching.csproj | 1 - src/GameCult.Caching/README.md | 8 +- .../GameCult.Networking.csproj | 3 - .../GameCult.Caching.ConsumerBuild.csproj | 4 - .../GameCult.Caching.InteropPeer.csproj | 3 - .../BackingStoreTests.cs | 41 +- .../CultDocumentRegistryTests.cs | 3 + .../DocumentShapeSweepTests.cs | 253 ------- .../DocumentShapeTests.cs | 291 +++++++ .../GameCult.Caching.Tests.csproj | 7 - tests/GameCult.Caching.Tests/README.md | 4 +- .../SerializationOptionsTests.cs | 88 +-- .../UnkeyedDocumentMemberTests.cs | 66 -- .../GameCult.Networking.InteropPeer.csproj | 3 - 31 files changed, 453 insertions(+), 1639 deletions(-) delete mode 100644 src/GameCult.Caching.MessagePack.Analyzers/Class1.cs delete mode 100644 src/GameCult.Caching.MessagePack.Analyzers/GameCult.Caching.MessagePack.Analyzers.csproj delete mode 100644 src/GameCult.Caching.MessagePack.Analyzers/README.md delete mode 100644 src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs delete mode 100644 src/GameCult.Caching.MessagePack.Generator/GameCult.Caching.MessagePack.Generator.csproj delete mode 100644 src/GameCult.Caching.MessagePack.Generator/README.md delete mode 100644 src/GameCult.Caching/CultGeneratedDocumentMetadata.cs create mode 100644 src/GameCult.Caching/CultSchemaTypeNames.cs delete mode 100644 tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs create mode 100644 tests/GameCult.Caching.Tests/DocumentShapeTests.cs delete mode 100644 tests/GameCult.Caching.Tests/UnkeyedDocumentMemberTests.cs 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 @@ -