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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-13 23:38:08 +02:00
commit 96ee5fdce3
31 changed files with 453 additions and 1639 deletions

View file

@ -11,10 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "src\Gam
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}"
EndProject 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking", "src\GameCult.Networking\GameCult.Networking.csproj", "{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh", "src\GameCult.Mesh\GameCult.Mesh.csproj", "{8D1B01A7-BF9E-44E6-88E1-B17255728B42}" 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|x64.Build.0 = Release|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.ActiveCfg = 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 {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.ActiveCfg = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.Build.0 = 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 {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} {72AEDD42-E881-4EB7-99A8-FD3B9667189C} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{9DDA917D-B4DB-4AC1-9B9A-4ECF06A5ABA1} = {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} {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} {67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{8D1B01A7-BF9E-44E6-88E1-B17255728B42} = {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} {E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}

View file

@ -98,8 +98,6 @@ The solution includes:
- `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions - `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions
- `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache - `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache
- `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-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.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.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 - `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/
GameCult.Caching.MessagePack/ GameCult.Caching.MessagePack/
GameCult.Caching.NewtonsoftJson/ GameCult.Caching.NewtonsoftJson/
GameCult.Caching.MessagePack.Generator/
GameCult.Caching.MessagePack.Analyzers/
GameCult.Geometry/ GameCult.Geometry/
GameCult.Networking/ GameCult.Networking/
GameCult.Networking.WebSockets/ 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](src/GameCult.Caching/README.md)
- [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md) - [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md)
- [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/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.Geometry](src/GameCult.Geometry/README.md)
- [GameCult.Networking](src/GameCult.Networking/README.md) - [GameCult.Networking](src/GameCult.Networking/README.md)
- [GameCult.Mesh](src/GameCult.Mesh/README.md) - [GameCult.Mesh](src/GameCult.Mesh/README.md)

View file

@ -16,7 +16,6 @@ $outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) {
$projects = @( $projects = @(
"src\GameCult.Logging\GameCult.Logging.csproj", "src\GameCult.Logging\GameCult.Logging.csproj",
"src\GameCult.Caching\GameCult.Caching.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.Caching.MessagePack\GameCult.Caching.MessagePack.csproj",
"src\GameCult.Networking\GameCult.Networking.csproj", "src\GameCult.Networking\GameCult.Networking.csproj",
"src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj",
@ -53,7 +52,6 @@ $version = [regex]::Match($meshPackage.Name, '^GameCult\.Mesh\.(.+)\.nupkg$').Gr
$expectedPackages = @( $expectedPackages = @(
"GameCult.Logging.$version.nupkg", "GameCult.Logging.$version.nupkg",
"GameCult.Caching.$version.nupkg", "GameCult.Caching.$version.nupkg",
"GameCult.Caching.MessagePack.Analyzers.$version.nupkg",
"GameCult.Caching.MessagePack.$version.nupkg", "GameCult.Caching.MessagePack.$version.nupkg",
"GameCult.Networking.$version.nupkg", "GameCult.Networking.$version.nupkg",
"GameCult.Networking.WebSockets.$version.nupkg", "GameCult.Networking.WebSockets.$version.nupkg",

View file

@ -1,11 +0,0 @@
using System;
namespace GameCult.Caching.MessagePack.Analyzer
{
/// <summary>
/// Placeholder type for the analyzer packaging project.
/// </summary>
public class Class1
{
}
}

View file

@ -1,33 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Source generator that emits MessagePack formatters for DatabaseEntry subclasses</Description>
<PackageTags>MessagePack;SourceGenerator;DatabaseEntry;Analyzer</PackageTags>
<IncludeBuildOutput>false</IncludeBuildOutput>
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);PackBuildOutputs</TargetsForTfmSpecificContentInPackage>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<IncludeSymbols>false</IncludeSymbols>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<!-- Reference the generator project, but not for compile -->
<ProjectReference Include="..\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
ExcludeAssets="compile" />
</ItemGroup>
<Target Name="DebugResolvedProjectReferences" AfterTargets="ResolveProjectReferences">
<Message Importance="High" Text="ResolvedProjectReferencePaths = %(_ResolvedProjectReferencePaths.Identity)" />
</Target>
<!-- This target must run after ResolveProjectReferences and ensure the generator was built -->
<Target Name="PackBuildOutputs" DependsOnTargets="ResolveProjectReferences;DebugSymbolsProjectOutputGroup">
<ItemGroup>
<!-- Put generator DLL into the analyzers folder -->
<TfmSpecificPackageFile Include="%(_ResolvedProjectReferencePaths.Identity)" PackagePath="analyzers/dotnet/cs/" />
</ItemGroup>
</Target>
</Project>

View file

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

View file

@ -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
{
/// <summary>
/// Emits MessagePack payload serializers for CultCache document types.
/// </summary>
[Generator]
public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
{
/// <summary>
/// Registers the generator pipeline.
/// </summary>
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<string>();
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<MemberShape> DiscoverMembers(INamedTypeSymbol typeSymbol, List<string> rejections)
{
var documentName = typeSymbol.Name;
var documentStem = SanitizeIdentifier(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", string.Empty).Replace(".", "_"));
var candidates = new List<MemberCandidate>();
var seenRoots = new HashSet<ISymbol>(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<IPropertySymbol> 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<DocumentShape> 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("// <auto-generated/>");
builder.AppendLine("#nullable enable");
builder.AppendLine($"[assembly: global::GameCult.Caching.CultGeneratedDocumentMetadataProviderAttribute(typeof(global::GameCult.Caching.Generated.{providerName}))]");
builder.AppendLine("namespace GameCult.Caching.Generated");
builder.AppendLine("{");
builder.AppendLine($" internal sealed class {providerName} : global::GameCult.Caching.ICultGeneratedDocumentMetadataProvider");
builder.AppendLine(" {");
builder.AppendLine(" private static readonly global::GameCult.Caching.CultGeneratedDocumentDefinition[] s_documents =");
builder.AppendLine(" {");
foreach (var document in documents)
{
builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentDefinition(");
builder.AppendLine($" typeof({document.DocumentTypeName}),");
builder.AppendLine($" \"{Escape(document.SchemaName)}\",");
builder.AppendLine($" \"{Escape(document.SchemaVersion)}\",");
builder.AppendLine($" {(document.IsGlobal ? "true" : "false")},");
builder.AppendLine(document.NameMember == null
? " null,"
: $" \"{Escape(document.NameMember)}\",");
builder.AppendLine(document.NameAccessorMethodName == null
? " null,"
: $" {document.NameAccessorMethodName},");
builder.AppendLine(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<global::GameCult.Caching.CultGeneratedDocumentDefinition> 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<byte>();");
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<string>();
var current = typeSymbol;
while (current != null)
{
parts.Push(current.Name);
current = current.ContainingType;
}
var namespaceName = typeSymbol.ContainingNamespace?.ToDisplayString();
return string.IsNullOrWhiteSpace(namespaceName)
? string.Join(".", parts)
: namespaceName + "." + string.Join(".", parts);
}
private static bool CanBeNull(ITypeSymbol typeSymbol)
{
if (typeSymbol.NullableAnnotation == NullableAnnotation.Annotated)
{
return true;
}
if (typeSymbol.IsReferenceType)
{
return true;
}
return typeSymbol is INamedTypeSymbol namedType &&
namedType.IsGenericType &&
namedType.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T;
}
private sealed class 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<IndexAccessorShape> indexAccessors,
ImmutableArray<MemberShape> members,
bool canConstructForDeserialization,
bool hasDenseSlots,
ImmutableArray<string> 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<IndexAccessorShape> IndexAccessors { get; }
public ImmutableArray<MemberShape> Members { get; }
public bool CanConstructForDeserialization { get; }
public bool HasDenseSlots { get; }
public ImmutableArray<string> 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; }
}
}
}

View file

@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsRoslynComponent>true</IsRoslynComponent>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TargetFramework>netstandard2.0</TargetFramework>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" PrivateAssets="all" />
</ItemGroup>
</Project>

View file

@ -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_<Assembly>` 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).

View file

@ -77,15 +77,7 @@ public static class CultDocumentMessagePackSerialization
public static byte[] SerializeUntyped(object value, Type type, CultDocumentRegistry registry) public static byte[] SerializeUntyped(object value, Type type, CultDocumentRegistry registry)
{ {
if (registry == null) throw new ArgumentNullException(nameof(registry)); if (registry == null) throw new ArgumentNullException(nameof(registry));
if (value != null) if (value != null) registry.GetRequired(type);
{
var descriptor = registry.GetRequired(type);
if (descriptor.GeneratedPayloadSerializer != null)
{
return descriptor.GeneratedPayloadSerializer(value);
}
}
return MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly)); 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) public static object DeserializeUntyped(Type type, byte[] payload, CultDocumentRegistry registry)
{ {
if (registry == null) throw new ArgumentNullException(nameof(registry)); if (registry == null) throw new ArgumentNullException(nameof(registry));
var descriptor = registry.GetRequired(type); registry.GetRequired(type);
if (descriptor.GeneratedPayloadDeserializer != null)
{
return descriptor.GeneratedPayloadDeserializer(payload);
}
return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type.Assembly)) return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type.Assembly))
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
} }

View file

@ -6,9 +6,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" /> <ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<!-- Bring in analyzers project so its DLL is packed alongside -->
<ProjectReference Include="..\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj"
PrivateAssets="none" />
<PackageReference Include="MessagePack" Version="3.1.7" /> <PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" /> <PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
</ItemGroup> </ItemGroup>

View file

@ -10,16 +10,16 @@ format for the attribute-first `GameCult.Caching` stack.
- `CultDocumentResolver` - `CultDocumentResolver`
- `CultRecordRefFormatter<T>` - `CultRecordRefFormatter<T>`
When a project declares `[CultDocument]` models, wire in the Cult document payloads serialize through `MessagePackSerializer` with the
`GameCult.Caching.MessagePack.Generator` analyzer there as well. It emits options of the document's assembly (`CultDocumentMessagePackSerialization.OptionsFor`),
assembly-local metadata providers for the cache registry. Cult documents then after the reflective `CultDocumentRegistry` has accepted the document shape. The
serialize through explicit generated slot codecs, while the store snapshot store snapshot format is written by hand against
format is written by hand against `MessagePackWriter`/`MessagePackReader`. `MessagePackWriter`/`MessagePackReader`.
## What It Does ## What It Does
- stores whole CultCache snapshots in MessagePack - 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<T>` values compact on disk/wire - keeps explicit `CultRecordRef<T>` values compact on disk/wire
- preserves the cache/store split where metadata lives outside the domain model - 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 - Payload bytes are MessagePack. Store metadata and schema catalogs are also
persisted through hand-written MessagePack array layouts in this package. 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 - The raw CultNet document lane should treat these payload bytes as already
blessed, not decode and re-encode them for sport. blessed, not decode and re-encode them for sport.

View file

@ -30,8 +30,14 @@ documents and the expected receipts.
A member's slot is its MessagePack `[Key(n)]` integer. MessagePack's `[Key]` and 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 `[IgnoreMember]` are the single slot authority, so `GameCult.Caching` depends on
`MessagePack.Annotations`. The registry and the generator (diagnostic GCC001) `MessagePack.Annotations`. Payloads are written by `MessagePackSerializer`, so
refuse, with the same message: a persisted member without `[Key]`, a string 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 `[Key]`, two members sharing a slot (including `new`-hidden members), a hidden
persisted member, an override whose `[Key]` or `[IgnoreMember]` differs from its persisted member, an override whose `[Key]` or `[IgnoreMember]` differs from its
base declaration's (MessagePack reads the base declaration), and a persisted base declaration's (MessagePack reads the base declaration), and a persisted

View file

@ -213,8 +213,7 @@ another runtime need not obey the `storedAt` rule.
- Options belong to the assembly that declares a document. That assembly names - Options belong to the assembly that declares a document. That assembly names
its formatter resolvers with `[assembly: CultCacheFormatterResolver(typeof(R))]`; its formatter resolvers with `[assembly: CultCacheFormatterResolver(typeof(R))]`;
generated and reflective serializers resolve options from the document's the payload serializer resolves options from the document's assembly. There is no mutable static, no registration call, and no load-order
assembly. There is no mutable static, no registration call, and no load-order
rule. rule.
- A generic document's options come from the generic definition's assembly: - A generic document's options come from the generic definition's assembly:
`Doc<X>` uses the resolvers `Doc`'s assembly declares, never `X`'s. A formatter `Doc<X>` uses the resolvers `Doc`'s assembly declares, never `X`'s. A formatter

View file

@ -12,6 +12,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MessagePack;
using R3; using R3;
namespace GameCult.Caching namespace GameCult.Caching
@ -120,8 +121,6 @@ namespace GameCult.Caching
bool isGlobal, bool isGlobal,
string? nameMember, string? nameMember,
Func<object, string?>? nameAccessor, Func<object, string?>? nameAccessor,
Func<object, byte[]>? generatedPayloadSerializer,
Func<byte[], object>? generatedPayloadDeserializer,
IReadOnlyDictionary<string, Func<object, string>> indexAccessors, IReadOnlyDictionary<string, Func<object, string>> indexAccessors,
IReadOnlyList<CultDocumentMemberDescriptor> members) IReadOnlyList<CultDocumentMemberDescriptor> members)
{ {
@ -134,8 +133,6 @@ namespace GameCult.Caching
IsGlobal = isGlobal; IsGlobal = isGlobal;
NameMember = nameMember; NameMember = nameMember;
NameAccessor = nameAccessor; NameAccessor = nameAccessor;
GeneratedPayloadSerializer = generatedPayloadSerializer;
GeneratedPayloadDeserializer = generatedPayloadDeserializer;
IndexAccessors = indexAccessors; IndexAccessors = indexAccessors;
Members = members; Members = members;
} }
@ -149,8 +146,6 @@ namespace GameCult.Caching
public bool IsGlobal { get; } public bool IsGlobal { get; }
public string? NameMember { get; } public string? NameMember { get; }
internal Func<object, string?>? NameAccessor { get; } internal Func<object, string?>? NameAccessor { get; }
public Func<object, byte[]>? GeneratedPayloadSerializer { get; }
public Func<byte[], object>? GeneratedPayloadDeserializer { get; }
internal IReadOnlyDictionary<string, Func<object, string>> IndexAccessors { get; } internal IReadOnlyDictionary<string, Func<object, string>> IndexAccessors { get; }
internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; } internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; }
@ -264,15 +259,7 @@ namespace GameCult.Caching
lock (_registrationGate) lock (_registrationGate)
{ {
var rebuilt = new RegistryIndexes(); var rebuilt = new RegistryIndexes();
var generatedTypes = new HashSet<Type>(); foreach (var type in ReflectionExtensions.GetAttributedDocumentTypes())
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)))
{ {
RegisterDescriptor(rebuilt, BuildDescriptor(type)); RegisterDescriptor(rebuilt, BuildDescriptor(type));
} }
@ -288,12 +275,6 @@ namespace GameCult.Caching
return descriptor; return descriptor;
} }
descriptor = TryBuildGeneratedDescriptor(type);
if (descriptor != null)
{
return RegisterDescriptor(descriptor);
}
var attribute = type.GetCustomAttribute<CultDocumentAttribute>(); var attribute = type.GetCustomAttribute<CultDocumentAttribute>();
if (attribute == null) if (attribute == null)
{ {
@ -472,16 +453,7 @@ namespace GameCult.Caching
public Dictionary<string, CultDocumentDescriptor[]> BySchemaName { get; } public Dictionary<string, CultDocumentDescriptor[]> BySchemaName { get; }
} }
private static CultDocumentDescriptor? TryBuildGeneratedDescriptor(Type type) private static CultDocumentDescriptor BuildDescriptor(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)
{ {
var attribute = type.GetCustomAttribute<CultDocumentAttribute>() var attribute = type.GetCustomAttribute<CultDocumentAttribute>()
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
@ -522,49 +494,10 @@ namespace GameCult.Caching
type.GetCustomAttribute<CultGlobalAttribute>() != null, type.GetCustomAttribute<CultGlobalAttribute>() != null,
nameMember?.Member.Name, nameMember?.Member.Name,
nameMember?.GetterNullable, nameMember?.GetterNullable,
null,
null,
indexAccessors, indexAccessors,
descriptorMembers); 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( private static string BuildSemanticFingerprint(
string schemaName, string schemaName,
string schemaVersion, string schemaVersion,
@ -900,7 +833,6 @@ namespace GameCult.Caching
public int[] DefaultedMissingSlots { get; } 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 // 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. // attributes up its override chain, as MessagePack's own resolver reads them.
private static IReadOnlyList<PersistedMember> DiscoverMembers(Type type) private static IReadOnlyList<PersistedMember> DiscoverMembers(Type type)
@ -938,7 +870,25 @@ namespace GameCult.Caching
} }
var rejections = new List<string>(); var rejections = new List<string>();
var keyed = new List<(MemberInfo Member, Type MemberType, int Slot)>(); var messagePackObject = type.GetCustomAttribute<MessagePackObjectAttribute>(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<MemberInfo>().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)) foreach (var candidate in candidates.OrderBy(candidate => candidate.Depth).ThenBy(candidate => candidate.Member.Name, StringComparer.Ordinal))
{ {
var key = GetKeyValue(candidate.Member); 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 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) => 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)]."; $"Cult document {documentTypeName} member {memberName} has no [Key]; every persisted member of a [CultDocument] type needs an explicit [Key(n)].";

View file

@ -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<CultGeneratedDocumentDefinition> GetDocumentDefinitions();
}
public sealed class CultGeneratedDocumentDefinition
{
public CultGeneratedDocumentDefinition(
Type documentType,
string schemaName,
string schemaVersion,
bool isGlobal,
string? nameMember,
Func<object, string?>? nameAccessor,
Func<object, byte[]>? serializePayload,
Func<byte[], object>? deserializePayload,
IReadOnlyList<CultGeneratedDocumentIndexAccessor> indexAccessors,
IReadOnlyList<CultGeneratedDocumentMemberDefinition> members)
{
DocumentType = documentType ?? throw new ArgumentNullException(nameof(documentType));
SchemaName = string.IsNullOrWhiteSpace(schemaName)
? throw new ArgumentException("Schema name must be non-empty.", nameof(schemaName))
: schemaName;
SchemaVersion = string.IsNullOrWhiteSpace(schemaVersion)
? throw new ArgumentException("Schema version must be non-empty.", nameof(schemaVersion))
: schemaVersion;
IsGlobal = isGlobal;
NameMember = nameMember;
NameAccessor = nameAccessor;
SerializePayload = serializePayload;
DeserializePayload = deserializePayload;
IndexAccessors = indexAccessors ?? Array.Empty<CultGeneratedDocumentIndexAccessor>();
Members = members ?? Array.Empty<CultGeneratedDocumentMemberDefinition>();
}
public Type DocumentType { get; }
public string SchemaName { get; }
public string SchemaVersion { get; }
public bool IsGlobal { get; }
public string? NameMember { get; }
public Func<object, string?>? NameAccessor { get; }
public Func<object, byte[]>? SerializePayload { get; }
public Func<byte[], object>? DeserializePayload { get; }
public IReadOnlyList<CultGeneratedDocumentIndexAccessor> IndexAccessors { get; }
public IReadOnlyList<CultGeneratedDocumentMemberDefinition> Members { get; }
}
public sealed class CultGeneratedDocumentIndexAccessor
{
public CultGeneratedDocumentIndexAccessor(string alias, Func<object, string> accessor)
{
Alias = string.IsNullOrWhiteSpace(alias)
? throw new ArgumentException("Alias must be non-empty.", nameof(alias))
: alias;
Accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
}
public string Alias { get; }
public Func<object, string> Accessor { get; }
}
public sealed class CultGeneratedDocumentMemberDefinition
{
public CultGeneratedDocumentMemberDefinition(
string memberName,
int slot,
string typeName,
bool isReference,
bool isMany,
string? targetSchemaName,
bool isName,
string? indexAlias)
{
MemberName = string.IsNullOrWhiteSpace(memberName)
? throw new ArgumentException("Member name must be non-empty.", nameof(memberName))
: memberName;
Slot = slot;
TypeName = string.IsNullOrWhiteSpace(typeName)
? throw new ArgumentException("Type name must be non-empty.", nameof(typeName))
: typeName;
IsReference = isReference;
IsMany = isMany;
TargetSchemaName = targetSchemaName;
IsName = isName;
IndexAlias = indexAlias;
}
public string MemberName { get; }
public int Slot { get; }
public string TypeName { get; }
public bool IsReference { get; }
public bool IsMany { get; }
public string? TargetSchemaName { get; }
public bool IsName { get; }
public string? IndexAlias { get; }
}
internal static class CultGeneratedDocumentMetadataLoader
{
public static IEnumerable<CultGeneratedDocumentDefinition> LoadDefinitions()
{
return AppDomain.CurrentDomain.GetAssemblies()
.Where(assembly => !assembly.IsDynamic)
.SelectMany(LoadDefinitions);
}
public static IEnumerable<CultGeneratedDocumentDefinition> LoadDefinitions(Assembly assembly)
{
foreach (var attribute in assembly.GetCustomAttributes<CultGeneratedDocumentMetadataProviderAttribute>())
{
if (!typeof(ICultGeneratedDocumentMetadataProvider).IsAssignableFrom(attribute.ProviderType))
{
continue;
}
if (Activator.CreateInstance(attribute.ProviderType) is not ICultGeneratedDocumentMetadataProvider provider)
{
continue;
}
foreach (var definition in provider.GetDocumentDefinitions())
{
yield return definition;
}
}
}
}
internal static class CultSchemaTypeNames
{
public static string FromType(Type type)
{
if (type.IsArray)
{
return FromType(type.GetElementType()!) + "[]";
}
if (type.IsGenericType)
{
var definition = type.GetGenericTypeDefinition();
var name = definition.FullName ?? definition.Name;
var tickIndex = name.IndexOf('`');
if (tickIndex >= 0)
{
name = name[..tickIndex];
}
var arguments = string.Join(", ", type.GetGenericArguments().Select(FromType));
return $"{name}<{arguments}>";
}
return type.FullName ?? type.Name;
}
public static string EscapeForLiteral(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var builder = new StringBuilder(value.Length);
foreach (var character in value)
{
builder.Append(character switch
{
'\\' => "\\\\",
'"' => "\\\"",
'\r' => "\\r",
'\n' => "\\n",
'\t' => "\\t",
_ => character.ToString()
});
}
return builder.ToString();
}
}
}

View file

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

View file

@ -8,6 +8,5 @@
<PackageReference Include="MessagePack.Annotations" Version="3.1.7" /> <PackageReference Include="MessagePack.Annotations" Version="3.1.7" />
<PackageReference Include="R3" Version="1.3.0" /> <PackageReference Include="R3" Version="1.3.0" />
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" /> <PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -37,11 +37,9 @@ Available cache attributes:
- `CultGlobal` - `CultGlobal`
- `CultReference(targetType?, many: false)` - `CultReference(targetType?, many: false)`
If you want compile-time document metadata instead of runtime assembly `CultDocumentRegistry` builds every document descriptor by reflection over
spelunking, add the `GameCult.Caching.MessagePack.Generator` analyzer to the these attributes and MessagePack's `[Key]`/`[IgnoreMember]`; it is the only
project that declares your `[CultDocument]` types. It emits a metadata provider descriptor authority.
the cache registry can load directly, while reflection stays behind as fallback
for projects that have not taken the hint yet.
## Main Concepts ## Main Concepts

View file

@ -7,9 +7,6 @@
<ProjectReference Include="..\GameCult.Logging\GameCult.Logging.csproj" /> <ProjectReference Include="..\GameCult.Logging\GameCult.Logging.csproj" />
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" /> <ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" /> <ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<PackageReference Include="MessagePack" Version="3.1.7" /> <PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" /> <PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
<PackageReference Include="System.Text.Json" Version="9.0.10" /> <PackageReference Include="System.Text.Json" Version="9.0.10" />

View file

@ -4,13 +4,9 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" /> <ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -8,9 +8,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" /> <ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<PackageReference Include="MessagePack" Version="3.1.7" /> <PackageReference Include="MessagePack" Version="3.1.7" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -726,47 +726,30 @@ namespace GameCult.Caching.Tests
} }
[Test] [Test]
public void GeneratedMetadataProvider_Emits_AttributedDocuments_And_References() public void Registry_Describes_AttributedDocuments_And_References()
{ {
var providers = typeof(NamedTestEntry).Assembly var named = CultDocumentRegistry.Shared.GetRequired<NamedTestEntry>();
.GetCustomAttributes(typeof(CultGeneratedDocumentMetadataProviderAttribute), false)
.Cast<CultGeneratedDocumentMetadataProviderAttribute>()
.ToArray();
Assert.That(providers, Is.Not.Empty);
var definitions = providers
.SelectMany(provider =>
((ICultGeneratedDocumentMetadataProvider)Activator.CreateInstance(provider.ProviderType)!)
.GetDocumentDefinitions())
.ToArray();
var named = definitions.Single(definition => definition.DocumentType == typeof(NamedTestEntry));
Assert.That(named.SchemaName, Is.EqualTo("tests.named_entry")); Assert.That(named.SchemaName, Is.EqualTo("tests.named_entry"));
Assert.That(named.NameMember, Is.EqualTo(nameof(NamedTestEntry.Name))); Assert.That(named.NameMember, Is.EqualTo(nameof(NamedTestEntry.Name)));
var referenceHolder = definitions.Single(definition => definition.DocumentType == typeof(ReferenceHolderEntry)); var parentMember = CultDocumentRegistry.Shared.GetRequired<ReferenceHolderEntry>().ToCatalogEntry().Members
var parentMember = referenceHolder.Members.Single(member => member.MemberName == nameof(ReferenceHolderEntry.Parent)); .Single(member => member.MemberName == nameof(ReferenceHolderEntry.Parent));
Assert.That(parentMember.IsReference, Is.True); Assert.That(parentMember.IsReference, Is.True);
Assert.That(parentMember.TargetSchemaName, Is.EqualTo("tests.named_entry")); Assert.That(parentMember.TargetSchemaName, Is.EqualTo("tests.named_entry"));
Assert.That(parentMember.TypeName, Does.Contain("CultRecordRef")); Assert.That(parentMember.TypeName, Does.Contain("CultRecordRef"));
} }
[Test] [Test]
public void GeneratedMetadataProvider_Emits_Payload_Codecs_For_Plain_CultDocuments() public void Plain_CultDocument_Payload_RoundTrips()
{ {
var descriptor = CultDocumentRegistry.Shared.GetRequired<NamedTestEntry>();
var original = new NamedTestEntry var original = new NamedTestEntry
{ {
Name = "Teeth", Name = "Teeth",
Value = "slot-array" Value = "slot-array"
}; };
Assert.That(descriptor.GeneratedPayloadSerializer, Is.Not.Null); var payload = CultDocumentMessagePackSerialization.SerializeUntyped(original, typeof(NamedTestEntry));
Assert.That(descriptor.GeneratedPayloadDeserializer, Is.Not.Null); var roundTrip = (NamedTestEntry)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(NamedTestEntry), payload);
var payload = descriptor.GeneratedPayloadSerializer!(original);
var roundTrip = (NamedTestEntry)descriptor.GeneratedPayloadDeserializer!(payload);
Assert.That(roundTrip.Name, Is.EqualTo("Teeth")); Assert.That(roundTrip.Name, Is.EqualTo("Teeth"));
Assert.That(roundTrip.Value, Is.EqualTo("slot-array")); 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}]}"; "{\"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 NamedFixtureSchemaId = "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8";
private const string NamedFixtureContentHash = "sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e"; 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")] [CultDocument("tests.named_entry", "tests.named_entry.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NamedTestEntry internal sealed class NamedTestEntry
{ {
[Key(0)] [Key(0)]
@ -1050,6 +1034,7 @@ namespace GameCult.Caching.Tests
} }
[CultDocument("tests.global_entry", "tests.global_entry.v1")] [CultDocument("tests.global_entry", "tests.global_entry.v1")]
[MessagePackObject(AllowPrivate = true)]
[CultGlobal] [CultGlobal]
internal sealed class GlobalTestEntry internal sealed class GlobalTestEntry
{ {
@ -1058,6 +1043,7 @@ namespace GameCult.Caching.Tests
} }
[CultDocument("tests.reference_holder", "tests.reference_holder.v1")] [CultDocument("tests.reference_holder", "tests.reference_holder.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class ReferenceHolderEntry internal sealed class ReferenceHolderEntry
{ {
[Key(0)] [Key(0)]
@ -1069,6 +1055,7 @@ namespace GameCult.Caching.Tests
} }
[CultDocument("tests.named_entry", "tests.named_entry.v2")] [CultDocument("tests.named_entry", "tests.named_entry.v2")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NamedTestEntryAdditive internal sealed class NamedTestEntryAdditive
{ {
[Key(0)] [Key(0)]
@ -1083,6 +1070,7 @@ namespace GameCult.Caching.Tests
} }
[CultDocument("tests.named_entry", "tests.named_entry.v3")] [CultDocument("tests.named_entry", "tests.named_entry.v3")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NamedTestEntryRemoved internal sealed class NamedTestEntryRemoved
{ {
[Key(0)] [Key(0)]
@ -1091,6 +1079,7 @@ namespace GameCult.Caching.Tests
} }
[CultDocument("tests.named_entry", "tests.named_entry.v4")] [CultDocument("tests.named_entry", "tests.named_entry.v4")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NamedTestEntryTypeMismatch internal sealed class NamedTestEntryTypeMismatch
{ {
[Key(0)] [Key(0)]
@ -1102,6 +1091,7 @@ namespace GameCult.Caching.Tests
} }
[CultDocument("tests.alt_named_entry", "tests.alt_named_entry.v1")] [CultDocument("tests.alt_named_entry", "tests.alt_named_entry.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class AlternateNamedTestEntry internal sealed class AlternateNamedTestEntry
{ {
[Key(0)] [Key(0)]
@ -1110,6 +1100,7 @@ namespace GameCult.Caching.Tests
} }
[CultDocument("tests.reference_holder", "tests.reference_holder.v2")] [CultDocument("tests.reference_holder", "tests.reference_holder.v2")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class ReferenceHolderRetargetedEntry internal sealed class ReferenceHolderRetargetedEntry
{ {
[Key(0)] [Key(0)]

View file

@ -140,6 +140,9 @@ public sealed class CultDocumentRegistryTests
type.SetCustomAttribute(new CustomAttributeBuilder( type.SetCustomAttribute(new CustomAttributeBuilder(
attributeConstructor, attributeConstructor,
new object[] { schemaName, schemaVersion })); new object[] { schemaName, schemaVersion }));
type.SetCustomAttribute(new CustomAttributeBuilder(
typeof(global::MessagePack.MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!,
new object[] { false }));
if (addStringField) if (addStringField)
{ {
type.DefineField("Value", typeof(string), FieldAttributes.Public).SetCustomAttribute(new CustomAttributeBuilder( type.DefineField("Value", typeof(string), FieldAttributes.Public).SetCustomAttribute(new CustomAttributeBuilder(

View file

@ -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<string> Tags = new List<string>();
[Key(4)] public Dictionary<string, int> Counts { get; set; } = new Dictionary<string, int>();
[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<string, byte[]> 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<string, byte[]>();
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<InvalidOperationException>(() => 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());
}
}
}

View file

@ -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<Plain>().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<Weapon>().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<object>()));
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<InvalidOperationException>(() => 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<string> Tags = new();
[Key(4)] public Dictionary<string, int> 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; }
}
}
}

View file

@ -4,18 +4,11 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>GameCult.Caching.Tests</RootNamespace> <RootNamespace>GameCult.Caching.Tests</RootNamespace>
<!-- The generator source is compiled in so UnkeyedDocumentMemberIsRejected can drive it. -->
<NoWarn>$(NoWarn);RS1036;RS1041</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Caching\GameCult.Caching.csproj" /> <ProjectReference Include="..\..\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" /> <ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<Compile Include="..\..\src\GameCult.Caching.MessagePack.Generator\CultDocumentMessagePackGenerator.cs" Link="Generator\CultDocumentMessagePackGenerator.cs" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -19,8 +19,8 @@ The tests currently cover:
- cache lookups (`Get`, `GetAll`, runtime-type upserts) - cache lookups (`Get`, `GetAll`, runtime-type upserts)
- `CultCache.Commit` batches on a directory store: visibility after the store - `CultCache.Commit` batches on a directory store: visibility after the store
commits, abandoned stages, sealed batches, observers committing again commits, abandoned stages, sealed batches, observers committing again
- explicit Cult document payload codecs, generated metadata, canonical schema - Cult document payloads, registry shape rejections, canonical schema fixtures,
fixtures, and schema drift reports and schema drift reports
- hand-written MessagePack snapshot and record serialization - hand-written MessagePack snapshot and record serialization
- `StoreRoutingTests`: the store-composition contract (routing, attachment is - `StoreRoutingTests`: the store-composition contract (routing, attachment is
hydration, read-only stores, global singletons, assignable lookups, batches, hydration, read-only stores, global singletons, assignable lookups, batches,

View file

@ -4,12 +4,12 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Reflection.Emit;
using System.Threading.Tasks; using System.Threading.Tasks;
using GameCult.Caching; using GameCult.Caching;
using GameCult.Caching.MessagePack; using GameCult.Caching.MessagePack;
using MessagePack; using MessagePack;
using Microsoft.CodeAnalysis; using MessagePack.Formatters;
using Microsoft.CodeAnalysis.CSharp;
using NUnit.Framework; using NUnit.Framework;
namespace GameCult.Caching.Tests namespace GameCult.Caching.Tests
@ -76,26 +76,17 @@ namespace GameCult.Caching.Tests
{ {
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(PairHolder) }); var registry = CultDocumentRegistry.ForTypes(new[] { typeof(PairHolder) });
var holder = new PairHolder { Name = "pair", Value = new Pair { A = 1.5f, B = -2f } }; var holder = new PairHolder { Name = "pair", Value = new Pair { A = 1.5f, B = -2f } };
Assert.That(registry.GetRequired<PairHolder>().GeneratedPayloadSerializer, Is.Not.Null, "no generated codec");
var generated = CultDocumentMessagePackSerialization.SerializeUntyped(holder, typeof(PairHolder), registry); var payload = 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));
Assert.That(reflective, Is.EqualTo(generated)); var reader = new MessagePackReader(payload);
var reader = new MessagePackReader(generated);
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2));
reader.Skip(); reader.Skip();
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2));
Assert.That(reader.ReadSingle(), Is.EqualTo(1.5f)); Assert.That(reader.ReadSingle(), Is.EqualTo(1.5f));
Assert.That(reader.ReadSingle(), Is.EqualTo(-2f)); Assert.That(reader.ReadSingle(), Is.EqualTo(-2f));
var decoded = (PairHolder)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(PairHolder), payload, registry);
var fromGenerated = (PairHolder)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(PairHolder), generated, registry); Assert.That(decoded.Value, Is.EqualTo(holder.Value));
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));
} }
[Test] [Test]
@ -113,52 +104,25 @@ namespace GameCult.Caching.Tests
[Test] [Test]
public void GenericDocumentUsesItsOwnAssemblyResolvers() public void GenericDocumentUsesItsOwnAssemblyResolvers()
{ {
const string source = @" var assembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("GenericHolders"), AssemblyBuilderAccess.Run, new[]
using GameCult.Caching; {
using GameCult.Caching.MessagePack; new CustomAttributeBuilder(typeof(CultCacheFormatterResolverAttribute).GetConstructor(new[] { typeof(Type) })!, new object[] { typeof(ReversedPairResolver) })
using GameCult.Caching.Tests; });
using MessagePack; var builder = assembly.DefineDynamicModule("GenericHolders").DefineType("GenericHolder`1", TypeAttributes.Public | TypeAttributes.Sealed);
using MessagePack.Formatters; var parameter = builder.DefineGenericParameters("T")[0];
[assembly: CultCacheFormatterResolver(typeof(ReversedPairResolver))] builder.SetCustomAttribute(new CustomAttributeBuilder(
[CultDocument(""tests.generic_holder"", ""tests.generic_holder.v1"")] typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, new object[] { "tests.generic_holder", "tests.generic_holder.v1" }));
[MessagePackObject] builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false }));
public sealed class GenericHolder<T> { [Key(0)] public T Value; } builder.DefineField("Value", parameter, FieldAttributes.Public)
public sealed class ReversedPairResolver : IFormatterResolver .SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { 0 }));
{ var type = builder.CreateType()!.MakeGenericType(typeof(Pair));
public static readonly IFormatterResolver Instance = new ReversedPairResolver();
public IMessagePackFormatter<T> GetFormatter<T>() => typeof(T) == typeof(Pair) ? (IMessagePackFormatter<T>)(object)new ReversedPairFormatter() : null;
}
public sealed class ReversedPairFormatter : IMessagePackFormatter<Pair>
{
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 registry = CultDocumentRegistry.ForTypes(new[] { type }); var registry = CultDocumentRegistry.ForTypes(new[] { type });
var holder = Activator.CreateInstance(type)!; var holder = Activator.CreateInstance(type)!;
type.GetField("Value")!.SetValue(holder, new Pair { A = 1.5f, B = -2f }); type.GetField("Value")!.SetValue(holder, new Pair { A = 1.5f, B = -2f });
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(holder, type, registry); 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); var reader = new MessagePackReader(payload);
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1)); Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1));
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2));
@ -168,6 +132,18 @@ public sealed class ReversedPairFormatter : IMessagePackFormatter<Pair>
Assert.That(type.GetField("Value")!.GetValue(decoded), Is.EqualTo(new Pair { A = 1.5f, B = -2f })); 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<T>? GetFormatter<T>() => typeof(T) == typeof(Pair) ? (IMessagePackFormatter<T>)(object)new ReversedPairFormatter() : null;
}
public sealed class ReversedPairFormatter : IMessagePackFormatter<Pair>
{
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")] [CultDocument("tests.pair_holder", "tests.pair_holder.v1")]
[MessagePackObject] [MessagePackObject]
public sealed class PairHolder public sealed class PairHolder
@ -177,6 +153,7 @@ public sealed class ReversedPairFormatter : IMessagePackFormatter<Pair>
} }
[CultDocument("cultcache.interop-note", "cultcache.interop_note.v1")] [CultDocument("cultcache.interop-note", "cultcache.interop_note.v1")]
[MessagePackObject]
public sealed class OptionsInteropNote public sealed class OptionsInteropNote
{ {
[Key(0)] public string SchemaVersion { get; set; } = "cultcache.interop_note.v1"; [Key(0)] public string SchemaVersion { get; set; } = "cultcache.interop_note.v1";
@ -188,6 +165,7 @@ public sealed class ReversedPairFormatter : IMessagePackFormatter<Pair>
} }
[CultDocument("tests.ref_keyed_holder", "tests.ref_keyed_holder.v1")] [CultDocument("tests.ref_keyed_holder", "tests.ref_keyed_holder.v1")]
[MessagePackObject]
public sealed class RefKeyedHolder public sealed class RefKeyedHolder
{ {
[Key(0)] [CultName] public string Name = string.Empty; [Key(0)] [CultName] public string Name = string.Empty;

View file

@ -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<InvalidOperationException>(() => 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()!;
}
}
}

View file

@ -8,9 +8,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Networking\GameCult.Networking.csproj" /> <ProjectReference Include="..\..\src\GameCult.Networking\GameCult.Networking.csproj" />
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<PackageReference Include="MessagePack" Version="3.1.7" /> <PackageReference Include="MessagePack" Version="3.1.7" />
</ItemGroup> </ItemGroup>
</Project> </Project>