Generate derived documents' members and require [Key] on every one

The generator walked only members declared on the document class and emitted
a codec only when those were dense, so a derived document's generated
descriptor hashed a different schema id and catalog than the reflective one.
It now discovers members along the base-type chain the way
CultDocumentRegistry reflects them (public instance fields and get/set
properties, inherited included, [IgnoreMember] excluded), and density is
over that full set: RoutingWeapon : RoutingGear gets a codec and the same
SchemaId and catalog (Name@0, Damage@1) from both builds.

Unkeyed members made the two orderings disagree (generator by name, registry
by metadata token). Both implicit-slot paths are deleted: an unkeyed
persisted member of a [CultDocument] type is generator error GCC001, and the
registry throws the same message. The consumer audit found no unkeyed
document with a persisted store.

Tests: GeneratedAndReflectiveDescriptorsAgreeForDerivedDocuments failed on
ea5a07b ("no generated codec for the derived document") and passes through
UpsertWeaponThroughGearHandleReloadsAsWeapon; UnkeyedDocumentMemberIsRejected
drives the generator through Roslyn and compares its diagnostic with the
registry's exception. The registry's reflective BuildDescriptor is internal
and visible to the tests for that comparison. The registry-collision fixture's
emitted field gains its [Key]; schema-compatibility fixture ids are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-13 21:19:08 +02:00
commit 9009aa79bc
8 changed files with 152 additions and 96 deletions

View file

@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
@ -49,7 +50,8 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
return null;
}
var members = DiscoverMembers(typeSymbol);
var unkeyedMembers = new List<string>();
var members = DiscoverMembers(typeSymbol, unkeyedMembers);
var nameMember = members.FirstOrDefault(member => member.IsName);
var canConstructForDeserialization = typeSymbol.TypeKind == TypeKind.Struct ||
typeSymbol.InstanceConstructors.Any(constructor =>
@ -71,68 +73,50 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
.ToImmutableArray(),
members.ToImmutableArray(),
canConstructForDeserialization,
hasDenseSlots);
hasDenseSlots,
unkeyedMembers.Select(name => UnkeyedMemberMessage(typeSymbol.Name, name)).ToImmutableArray(),
typeSymbol.Locations.FirstOrDefault());
}
private static ImmutableArray<MemberShape> DiscoverMembers(INamedTypeSymbol typeSymbol)
// The same members CultDocumentRegistry reflects: public instance fields and get/set properties, inherited included.
private static ImmutableArray<MemberShape> DiscoverMembers(INamedTypeSymbol typeSymbol, List<string> unkeyedMembers)
{
var candidates = new List<MemberShapeSeed>();
foreach (var member in typeSymbol.GetMembers())
var shaped = new List<MemberShape>();
for (var type = typeSymbol; type != null && type.SpecialType != SpecialType.System_Object; type = type.BaseType)
{
if (member is IFieldSymbol field)
foreach (var member in type.GetMembers())
{
if (field.IsStatic || field.DeclaredAccessibility != Accessibility.Public || IsIgnored(field))
{
ITypeSymbol memberType;
if (member is IFieldSymbol field && !field.IsStatic && field.DeclaredAccessibility == Accessibility.Public)
memberType = field.Type;
else if (member is IPropertySymbol property && !property.IsStatic && !property.IsIndexer && !property.IsOverride &&
property.DeclaredAccessibility == Accessibility.Public && property.GetMethod != null && property.SetMethod != null)
memberType = property.Type;
else
continue;
}
candidates.Add(MemberShapeSeed.From(field, field.Type));
continue;
}
if (member is IPropertySymbol property)
{
if (property.IsStatic ||
property.IsIndexer ||
property.DeclaredAccessibility != Accessibility.Public ||
property.GetMethod == null ||
property.SetMethod == null ||
IsIgnored(property))
{
if (IsIgnored(member))
continue;
}
candidates.Add(MemberShapeSeed.From(property, property.Type));
var slot = GetExplicitSlot(member);
if (slot == null)
unkeyedMembers.Add(member.Name);
else
shaped.Add(MemberShapeSeed.From(member, memberType).WithSlot(slot.Value));
}
}
var explicitMembers = candidates
.Where(member => member.ExplicitSlot.HasValue)
.OrderBy(member => member.ExplicitSlot!.Value)
.ThenBy(member => member.SortKey, StringComparer.Ordinal)
.ToArray();
var implicitMembers = candidates
.Where(member => !member.ExplicitSlot.HasValue)
.OrderBy(member => member.SortKey, StringComparer.Ordinal)
.ToArray();
var nextSlot = explicitMembers.Length == 0 ? 0 : explicitMembers.Max(member => member.ExplicitSlot!.Value) + 1;
var shaped = new List<MemberShape>(candidates.Count);
foreach (var member in explicitMembers)
{
shaped.Add(member.WithSlot(member.ExplicitSlot!.Value));
}
foreach (var member in implicitMembers)
{
shaped.Add(member.WithSlot(nextSlot++));
}
return shaped
.OrderBy(member => member.Slot)
.ToImmutableArray();
return shaped.OrderBy(member => member.Slot).ToImmutableArray();
}
// CultDocumentRegistry.UnkeyedMemberMessage throws the same text.
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)].";
#pragma warning disable RS2008
private static readonly DiagnosticDescriptor UnkeyedMember = new(
"GCC001", "Cult document member has no [Key]", "{0}", "GameCult.Caching", DiagnosticSeverity.Error, isEnabledByDefault: true);
#pragma warning restore RS2008
private static bool IsIgnored(ISymbol member)
{
return GetAttribute(member, "MessagePack.IgnoreMemberAttribute") != null;
@ -160,12 +144,19 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
private static void EmitProvider(SourceProductionContext context, Compilation compilation, ImmutableArray<DocumentShape> shapes)
{
var documents = 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.UnkeyedMemberMessages)
context.ReportDiagnostic(Diagnostic.Create(UnkeyedMember, shape.Location, message));
}
var documents = shaped.Where(shape => shape.UnkeyedMemberMessages.IsEmpty).ToArray();
if (documents.Length == 0)
{
@ -425,7 +416,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
string name,
string schemaTypeName,
string typeSyntaxName,
int? explicitSlot,
string sortKey,
bool isName,
string? indexAlias,
@ -437,7 +427,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
Name = name;
SchemaTypeName = schemaTypeName;
TypeSyntaxName = typeSyntaxName;
ExplicitSlot = explicitSlot;
SortKey = sortKey;
IsName = isName;
IndexAlias = indexAlias;
@ -450,7 +439,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
public string Name { get; }
public string SchemaTypeName { get; }
public string TypeSyntaxName { get; }
public int? ExplicitSlot { get; }
public string SortKey { get; }
public bool IsName { get; }
public string? IndexAlias { get; }
@ -501,7 +489,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
member.Name,
GetSchemaTypeName(memberType),
memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
GetExplicitSlot(member),
BuildAccessorStem(member),
GetAttribute(member, "GameCult.Caching.CultNameAttribute") != null,
indexAlias,
@ -546,8 +533,12 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
ImmutableArray<IndexAccessorShape> indexAccessors,
ImmutableArray<MemberShape> members,
bool canConstructForDeserialization,
bool hasDenseSlots)
bool hasDenseSlots,
ImmutableArray<string> unkeyedMemberMessages,
Location? location)
{
UnkeyedMemberMessages = unkeyedMemberMessages;
Location = location;
DocumentTypeName = documentTypeName;
SchemaName = schemaName;
SchemaVersion = schemaVersion;
@ -575,6 +566,8 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
public ImmutableArray<MemberShape> Members { get; }
public bool CanConstructForDeserialization { get; }
public bool HasDenseSlots { get; }
public ImmutableArray<string> UnkeyedMemberMessages { get; }
public Location? Location { get; }
}
private sealed class IndexAccessorShape

View file

@ -481,7 +481,7 @@ namespace GameCult.Caching
: BuildDescriptor(definition);
}
private static CultDocumentDescriptor BuildDescriptor(Type type)
internal static CultDocumentDescriptor BuildDescriptor(Type type)
{
var attribute = type.GetCustomAttribute<CultDocumentAttribute>()
?? throw new InvalidOperationException(
@ -906,44 +906,26 @@ namespace GameCult.Caching
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
{
if (IsIgnored(field))
{
continue;
}
members.Add(PersistedMember.FromMember(field, field.FieldType, value => field.GetValue(value), GetKeyValue(field)));
if (!IsIgnored(field))
members.Add(PersistedMember.FromMember(field, field.FieldType, value => field.GetValue(value), RequireKey(type, field)));
}
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (property.GetMethod == null || property.SetMethod == null || IsIgnored(property))
{
continue;
}
members.Add(PersistedMember.FromMember(property, property.PropertyType, value => property.GetValue(value), GetKeyValue(property)));
if (property.GetMethod != null && property.SetMethod != null && !IsIgnored(property))
members.Add(PersistedMember.FromMember(property, property.PropertyType, value => property.GetValue(value), RequireKey(type, property)));
}
var explicitMembers = members.Where(member => member.ExplicitSlot.HasValue).OrderBy(member => member.ExplicitSlot.GetValueOrDefault()).ToArray();
var implicitMembers = members.Where(member => !member.ExplicitSlot.HasValue).OrderBy(member => member.MetadataToken).ToArray();
var assigned = new List<PersistedMember>(members.Count);
var nextSlot = explicitMembers.Length == 0 ? 0 : explicitMembers.Max(member => member.ExplicitSlot.GetValueOrDefault()) + 1;
foreach (var member in explicitMembers)
{
member.Slot = member.ExplicitSlot.GetValueOrDefault();
assigned.Add(member);
}
foreach (var member in implicitMembers)
{
member.Slot = nextSlot++;
assigned.Add(member);
}
return assigned.OrderBy(member => member.Slot).ToArray();
return members.OrderBy(member => member.Slot).ToArray();
}
// The generator reports the same text as diagnostic GCC001.
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)].";
private static int RequireKey(Type documentType, MemberInfo member) =>
GetKeyValue(member) ?? throw new InvalidOperationException(UnkeyedMemberMessage(documentType.Name, member.Name));
private static bool IsIgnored(MemberInfo member)
{
return member.GetCustomAttributes().Any(attribute =>
@ -988,8 +970,6 @@ namespace GameCult.Caching
{
public MemberInfo Member { get; set; } = default!;
public Type MemberType { get; set; } = default!;
public int MetadataToken { get; set; }
public int? ExplicitSlot { get; set; }
public int Slot { get; set; }
public bool IsName { get; set; }
public string? IndexAlias { get; set; }
@ -1003,7 +983,7 @@ namespace GameCult.Caching
MemberInfo member,
Type memberType,
Func<object, object?> getValue,
int? explicitSlot)
int slot)
{
var referenceAttribute = member.GetCustomAttribute<CultReferenceAttribute>();
var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType);
@ -1012,9 +992,7 @@ namespace GameCult.Caching
{
Member = member,
MemberType = memberType,
MetadataToken = member.MetadataToken,
ExplicitSlot = explicitSlot,
Slot = explicitSlot ?? -1,
Slot = slot,
IsName = member.GetCustomAttribute<CultNameAttribute>() != null,
IndexAlias = ResolveIndexAlias(member),
IsReference = targetType != null || referenceAttribute != null,

View file

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

View file

@ -1,3 +1,4 @@
#nullable enable
using GameCult.Caching.MessagePack;
using GameCult.Caching.Tests;
using MessagePack;

View file

@ -142,7 +142,9 @@ public sealed class CultDocumentRegistryTests
new object[] { schemaName, schemaVersion }));
if (addStringField)
{
type.DefineField("Value", typeof(string), FieldAttributes.Public);
type.DefineField("Value", typeof(string), FieldAttributes.Public).SetCustomAttribute(new CustomAttributeBuilder(
typeof(global::MessagePack.KeyAttribute).GetConstructor(new[] { typeof(int) })!,
new object[] { 0 }));
}
return type.CreateType()!;

View file

@ -4,6 +4,8 @@
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<RootNamespace>GameCult.Caching.Tests</RootNamespace>
<!-- The generator source is compiled in so UnkeyedDocumentMemberIsRejected can drive it. -->
<NoWarn>$(NoWarn);RS1036;RS1041</NoWarn>
</PropertyGroup>
<ItemGroup>
@ -12,6 +14,8 @@
<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>

View file

@ -71,6 +71,21 @@ namespace GameCult.Caching.Tests
Assert.That(((RoutingWeapon)loaded!).Name, Is.EqualTo("lance"), "the inherited member did not round-trip");
}
[Test]
public async Task GeneratedAndReflectiveDescriptorsAgreeForDerivedDocuments()
{
var generated = CultDocumentRegistry.ForTypes(new[] { typeof(RoutingWeapon) }).GetRequired<RoutingWeapon>();
var reflective = CultDocumentRegistry.BuildDescriptor(typeof(RoutingWeapon));
Assert.That(generated.GeneratedPayloadSerializer, Is.Not.Null, "no generated codec for the derived document");
Assert.That(generated.GeneratedPayloadDeserializer, Is.Not.Null);
Assert.That(generated.SchemaId, Is.EqualTo(reflective.SchemaId));
Assert.That(generated.CanonicalSchemaJson, Is.EqualTo(reflective.CanonicalSchemaJson));
Assert.That(generated.Members.Select(member => $"{member.MemberName}@{member.Slot}"), Is.EqualTo(new[] { "Name@0", "Damage@1" }));
Assert.That(reflective.Members.Select(member => $"{member.MemberName}@{member.Slot}"), Is.EqualTo(new[] { "Name@0", "Damage@1" }));
await UpsertWeaponThroughGearHandleReloadsAsWeapon();
}
[Test]
public async Task GetByNameMatchesAssignableTypes()
{
@ -728,10 +743,7 @@ namespace GameCult.Caching.Tests
public string Text = string.Empty;
}
// RoutingWeapon's own slots are not dense, so it gets no generated codec and serializes through
// MessagePack's dynamic resolver, which needs a public [MessagePackObject].
[CultDocument("tests.routing_gear", "tests.routing_gear.v1")]
[MessagePackObject]
public class RoutingGear
{
[Key(0)]
@ -739,7 +751,6 @@ namespace GameCult.Caching.Tests
}
[CultDocument("tests.routing_weapon", "tests.routing_weapon.v1")]
[MessagePackObject]
public sealed class RoutingWeapon : RoutingGear
{
[Key(1)]

View file

@ -0,0 +1,66 @@
#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()!;
}
}
}