Make generated and reflective document members one rule
The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).
- Accessors are named per document, so documents sharing a [CultName] or
[CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
re-keyed override and serializes an override marked [IgnoreMember]).
An override that changes them is refused in both paths; an override
that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
generated codecs and declared resolvers; deleted. CultNetDocumentBinding
and CultMeshSnapshots route through the untyped path with their registry.
DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6d7868e0c7
commit
7c4bc2bdc0
12 changed files with 602 additions and 127 deletions
|
|
@ -50,8 +50,8 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
return null;
|
||||
}
|
||||
|
||||
var unkeyedMembers = new List<string>();
|
||||
var members = DiscoverMembers(typeSymbol, unkeyedMembers);
|
||||
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 =>
|
||||
|
|
@ -71,55 +71,139 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
members.Where(member => member.IndexAlias != null)
|
||||
.Select(member => new IndexAccessorShape(member.IndexAlias!, member.IndexAccessorMethodName!))
|
||||
.ToImmutableArray(),
|
||||
members.ToImmutableArray(),
|
||||
members,
|
||||
canConstructForDeserialization,
|
||||
hasDenseSlots,
|
||||
unkeyedMembers.Select(name => UnkeyedMemberMessage(typeSymbol.Name, name)).ToImmutableArray(),
|
||||
rejections.ToImmutableArray(),
|
||||
typeSymbol.Locations.FirstOrDefault());
|
||||
}
|
||||
|
||||
// The same members CultDocumentRegistry reflects: public instance fields and get/set properties, inherited included.
|
||||
private static ImmutableArray<MemberShape> DiscoverMembers(INamedTypeSymbol typeSymbol, List<string> unkeyedMembers)
|
||||
// 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 shaped = new List<MemberShape>();
|
||||
for (var type = typeSymbol; type != null && type.SpecialType != SpecialType.System_Object; type = type.BaseType)
|
||||
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())
|
||||
{
|
||||
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;
|
||||
|
||||
if (IsIgnored(member))
|
||||
continue;
|
||||
var slot = GetExplicitSlot(member);
|
||||
if (slot == null)
|
||||
unkeyedMembers.Add(member.Name);
|
||||
else
|
||||
shaped.Add(MemberShapeSeed.From(member, memberType).WithSlot(slot.Value));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shaped.OrderBy(member => member.Slot).ToImmutableArray();
|
||||
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();
|
||||
}
|
||||
|
||||
// CultDocumentRegistry.UnkeyedMemberMessage throws the same text.
|
||||
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 UnkeyedMember = new(
|
||||
"GCC001", "Cult document member has no [Key]", "{0}", "GameCult.Caching", DiagnosticSeverity.Error, isEnabledByDefault: true);
|
||||
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 GetAttribute(member, "MessagePack.IgnoreMemberAttribute") != null;
|
||||
return GetMemberAttribute(member, "MessagePack.IgnoreMemberAttribute") != null;
|
||||
}
|
||||
|
||||
private static AttributeData? GetAttribute(ISymbol symbol, string fullyQualifiedName)
|
||||
|
|
@ -128,18 +212,12 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
attribute.AttributeClass?.ToDisplayString() == fullyQualifiedName);
|
||||
}
|
||||
|
||||
private static int? GetExplicitSlot(ISymbol member)
|
||||
// Attribute.GetCustomAttributes(inherit: true) semantics: an override without the attribute inherits its base declaration's.
|
||||
private static AttributeData? GetMemberAttribute(ISymbol member, string fullyQualifiedName)
|
||||
{
|
||||
var keyAttribute = GetAttribute(member, "MessagePack.KeyAttribute");
|
||||
if (keyAttribute == null || keyAttribute.ConstructorArguments.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var argument = keyAttribute.ConstructorArguments[0];
|
||||
return argument.Kind == TypedConstantKind.Primitive && argument.Value is int slot
|
||||
? slot
|
||||
: null;
|
||||
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)
|
||||
|
|
@ -152,11 +230,11 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
.ToArray();
|
||||
foreach (var shape in shaped)
|
||||
{
|
||||
foreach (var message in shape.UnkeyedMemberMessages)
|
||||
context.ReportDiagnostic(Diagnostic.Create(UnkeyedMember, shape.Location, message));
|
||||
foreach (var message in shape.Rejections)
|
||||
context.ReportDiagnostic(Diagnostic.Create(RejectedMember, shape.Location, message));
|
||||
}
|
||||
|
||||
var documents = shaped.Where(shape => shape.UnkeyedMemberMessages.IsEmpty).ToArray();
|
||||
var documents = shaped.Where(shape => shape.Rejections.IsEmpty).ToArray();
|
||||
|
||||
if (documents.Length == 0)
|
||||
{
|
||||
|
|
@ -410,13 +488,30 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
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,
|
||||
string sortKey,
|
||||
bool isName,
|
||||
string? indexAlias,
|
||||
bool isReference,
|
||||
|
|
@ -427,7 +522,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
Name = name;
|
||||
SchemaTypeName = schemaTypeName;
|
||||
TypeSyntaxName = typeSyntaxName;
|
||||
SortKey = sortKey;
|
||||
IsName = isName;
|
||||
IndexAlias = indexAlias;
|
||||
IsReference = isReference;
|
||||
|
|
@ -439,7 +533,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
public string Name { get; }
|
||||
public string SchemaTypeName { get; }
|
||||
public string TypeSyntaxName { get; }
|
||||
public string SortKey { get; }
|
||||
public bool IsName { get; }
|
||||
public string? IndexAlias { get; }
|
||||
public bool IsReference { get; }
|
||||
|
|
@ -447,9 +540,11 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
public bool CanBeNull { get; }
|
||||
public string? TargetSchemaName { get; }
|
||||
|
||||
public MemberShape WithSlot(int slot)
|
||||
// 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 = SanitizeIdentifier(SortKey);
|
||||
var accessorStem = documentStem + "_" + SanitizeIdentifier(Name);
|
||||
return new MemberShape(
|
||||
Name,
|
||||
slot,
|
||||
|
|
@ -467,7 +562,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
|
||||
public static MemberShapeSeed From(ISymbol member, ITypeSymbol memberType)
|
||||
{
|
||||
var referenceAttribute = GetAttribute(member, "GameCult.Caching.CultReferenceAttribute");
|
||||
var referenceAttribute = GetMemberAttribute(member, "GameCult.Caching.CultReferenceAttribute");
|
||||
var explicitTarget = referenceAttribute?.ConstructorArguments.Length > 0
|
||||
? referenceAttribute.ConstructorArguments[0].Value as INamedTypeSymbol
|
||||
: null;
|
||||
|
|
@ -478,7 +573,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
var targetSchemaName = targetType == null
|
||||
? null
|
||||
: GetAttribute(targetType, "GameCult.Caching.CultDocumentAttribute")?.ConstructorArguments[0].Value as string;
|
||||
var indexAttribute = GetAttribute(member, "GameCult.Caching.CultIndexAttribute");
|
||||
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)
|
||||
|
|
@ -489,8 +584,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
member.Name,
|
||||
GetSchemaTypeName(memberType),
|
||||
memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
|
||||
BuildAccessorStem(member),
|
||||
GetAttribute(member, "GameCult.Caching.CultNameAttribute") != null,
|
||||
GetMemberAttribute(member, "GameCult.Caching.CultNameAttribute") != null,
|
||||
indexAlias,
|
||||
targetType != null || referenceAttribute != null,
|
||||
many,
|
||||
|
|
@ -512,13 +606,6 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
? namedType.TypeArguments[0] as INamedTypeSymbol
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string BuildAccessorStem(ISymbol member)
|
||||
{
|
||||
return member.ContainingType == null
|
||||
? member.Name
|
||||
: member.ContainingType.Name + "_" + member.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DocumentShape
|
||||
|
|
@ -534,10 +621,10 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
ImmutableArray<MemberShape> members,
|
||||
bool canConstructForDeserialization,
|
||||
bool hasDenseSlots,
|
||||
ImmutableArray<string> unkeyedMemberMessages,
|
||||
ImmutableArray<string> rejections,
|
||||
Location? location)
|
||||
{
|
||||
UnkeyedMemberMessages = unkeyedMemberMessages;
|
||||
Rejections = rejections;
|
||||
Location = location;
|
||||
DocumentTypeName = documentTypeName;
|
||||
SchemaName = schemaName;
|
||||
|
|
@ -566,7 +653,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator
|
|||
public ImmutableArray<MemberShape> Members { get; }
|
||||
public bool CanConstructForDeserialization { get; }
|
||||
public bool HasDenseSlots { get; }
|
||||
public ImmutableArray<string> UnkeyedMemberMessages { get; }
|
||||
public ImmutableArray<string> Rejections { get; }
|
||||
public Location? Location { get; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,16 +69,6 @@ public static class CultDocumentMessagePackSerialization
|
|||
$"Formatter resolver {resolverType.FullName} needs a public static Instance or a public parameterless constructor and must implement {nameof(IFormatterResolver)}.");
|
||||
}
|
||||
|
||||
public static byte[] Serialize<T>(T value)
|
||||
{
|
||||
return MessagePackSerializer.Serialize(value, Options);
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(byte[] payload)
|
||||
{
|
||||
return MessagePackSerializer.Deserialize<T>(payload, Options);
|
||||
}
|
||||
|
||||
public static byte[] SerializeUntyped(object value, Type type)
|
||||
{
|
||||
return SerializeUntyped(value, type, CultDocumentRegistry.Shared);
|
||||
|
|
|
|||
|
|
@ -900,31 +900,126 @@ namespace GameCult.Caching
|
|||
public int[] DefaultedMissingSlots { get; }
|
||||
}
|
||||
|
||||
// CultDocumentMessagePackGenerator.DiscoverMembers mirrors this step for step and reports the same rejections as GCC001.
|
||||
// Public instance fields and get/set properties, most-derived declaration first; GetCustomAttributes inherits a property's
|
||||
// attributes up its override chain, as MessagePack's own resolver reads them.
|
||||
private static IReadOnlyList<PersistedMember> DiscoverMembers(Type type)
|
||||
{
|
||||
var members = new List<PersistedMember>();
|
||||
|
||||
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
|
||||
const BindingFlags declared = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public;
|
||||
var candidates = new List<(MemberInfo Member, Type MemberType, int Depth, bool Writable, PropertyInfo? DivergentRoot)>();
|
||||
var seenRoots = new HashSet<(Type, string)>();
|
||||
var depth = 0;
|
||||
for (var current = type; current != null && current != typeof(object); current = current.BaseType, depth++)
|
||||
{
|
||||
if (!IsIgnored(field))
|
||||
members.Add(PersistedMember.FromMember(field, field.FieldType, value => field.GetValue(value), RequireKey(type, field)));
|
||||
foreach (var field in current.GetFields(declared))
|
||||
{
|
||||
if (!IsIgnored(field))
|
||||
candidates.Add((field, field.FieldType, depth, !field.IsInitOnly, null));
|
||||
}
|
||||
|
||||
foreach (var property in current.GetProperties(declared))
|
||||
{
|
||||
if (property.GetIndexParameters().Length > 0 || !seenRoots.Add(RootOf(property)))
|
||||
continue;
|
||||
var chain = OverrideChain(property).ToArray();
|
||||
var setter = chain.Select(declaration => declaration.SetMethod).FirstOrDefault(method => method != null);
|
||||
if (chain.All(declaration => declaration.GetMethod == null) || setter == null)
|
||||
continue;
|
||||
// MessagePack's resolver reads the base declaration's [Key]/[IgnoreMember]; an override that changes them is refused.
|
||||
var root = chain[chain.Length - 1];
|
||||
var divergentRoot = chain.Any(declaration =>
|
||||
!Equals(GetKeyValue(declaration), GetKeyValue(root)) || IsIgnored(declaration) != IsIgnored(root))
|
||||
? root
|
||||
: null;
|
||||
if (divergentRoot == null && IsIgnored(property))
|
||||
continue;
|
||||
candidates.Add((property, property.PropertyType, depth, IsWritable(setter), divergentRoot));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||
var rejections = new List<string>();
|
||||
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))
|
||||
{
|
||||
if (property.GetMethod != null && property.SetMethod != null && !IsIgnored(property))
|
||||
members.Add(PersistedMember.FromMember(property, property.PropertyType, value => property.GetValue(value), RequireKey(type, property)));
|
||||
var key = GetKeyValue(candidate.Member);
|
||||
if (candidate.DivergentRoot != null)
|
||||
rejections.Add(DivergentOverrideMessage(type.Name, Qualified(candidate.Member), Qualified(candidate.DivergentRoot)));
|
||||
else if (key is string)
|
||||
rejections.Add(StringKeyMessage(type.Name, candidate.Member.Name));
|
||||
else if (key is not int slot)
|
||||
rejections.Add(UnkeyedMemberMessage(type.Name, candidate.Member.Name));
|
||||
else
|
||||
{
|
||||
if (!candidate.Writable)
|
||||
rejections.Add(NotWritableMessage(type.Name, candidate.Member.Name));
|
||||
keyed.Add((candidate.Member, candidate.MemberType, slot));
|
||||
}
|
||||
}
|
||||
|
||||
return members.OrderBy(member => member.Slot).ToArray();
|
||||
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(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member), group.Key));
|
||||
}
|
||||
|
||||
foreach (var group in keyed.GroupBy(entry => entry.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(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member)));
|
||||
}
|
||||
|
||||
if (rejections.Count > 0)
|
||||
throw new InvalidOperationException(rejections[0]);
|
||||
|
||||
return keyed
|
||||
.Select(entry => PersistedMember.FromMember(
|
||||
entry.Member,
|
||||
entry.MemberType,
|
||||
entry.Member is FieldInfo field ? field.GetValue : ((PropertyInfo)entry.Member).GetValue,
|
||||
entry.Slot))
|
||||
.OrderBy(member => member.Slot)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// The generator reports the same text as diagnostic GCC001.
|
||||
private static (Type, string) RootOf(PropertyInfo property) =>
|
||||
((property.GetMethod ?? property.SetMethod)!.GetBaseDefinition().DeclaringType!, property.Name);
|
||||
|
||||
private static IEnumerable<PropertyInfo> OverrideChain(PropertyInfo property)
|
||||
{
|
||||
var root = RootOf(property);
|
||||
for (var current = property.DeclaringType; current != null; current = current.BaseType)
|
||||
{
|
||||
var declaration = current.GetProperty(property.Name, BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
if (declaration != null && declaration.GetIndexParameters().Length == 0 && RootOf(declaration) == root)
|
||||
yield return declaration;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWritable(MethodInfo setter) =>
|
||||
(setter.IsPublic || setter.IsAssembly || setter.IsFamilyOrAssembly) &&
|
||||
!setter.ReturnParameter.GetRequiredCustomModifiers().Any(modifier => modifier.FullName == "System.Runtime.CompilerServices.IsExternalInit");
|
||||
|
||||
private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name;
|
||||
|
||||
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 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 string HiddenMemberMessage(string documentTypeName, string first, string second) =>
|
||||
$"Cult document {documentTypeName} member {first} hides persisted member {second}; persisted member names must be unique.";
|
||||
|
||||
private static bool IsIgnored(MemberInfo member)
|
||||
{
|
||||
|
|
@ -935,24 +1030,19 @@ namespace GameCult.Caching
|
|||
});
|
||||
}
|
||||
|
||||
private static int? GetKeyValue(MemberInfo member)
|
||||
private static object? GetKeyValue(MemberInfo member)
|
||||
{
|
||||
foreach (var attribute in member.GetCustomAttributes())
|
||||
{
|
||||
var name = attribute.GetType().FullName;
|
||||
if (name == "MessagePack.KeyAttribute")
|
||||
{
|
||||
var property = attribute.GetType().GetProperty("IntKey");
|
||||
if (property?.GetValue(attribute) is int intKey)
|
||||
if (attribute.GetType().GetProperty("IntKey")?.GetValue(attribute) is int intKey)
|
||||
{
|
||||
return intKey;
|
||||
}
|
||||
|
||||
var ctorArg = attribute.GetType().GetProperty("StringKey");
|
||||
if (ctorArg?.GetValue(attribute) is string)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return attribute.GetType().GetProperty("StringKey")?.GetValue(attribute) as string;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1023,7 +1023,7 @@ namespace GameCult.Mesh
|
|||
}
|
||||
else
|
||||
{
|
||||
documents.Add(CultDocumentMessagePackSerialization.Deserialize<TDocument>(record.Payload));
|
||||
documents.Add((TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,8 @@ namespace GameCult.Networking
|
|||
Func<byte[], T>? payloadDeserializer = null)
|
||||
where T : class
|
||||
{
|
||||
var descriptor = (registry ?? CultDocumentRegistry.Shared).GetRequired<T>();
|
||||
var documents = registry ?? CultDocumentRegistry.Shared;
|
||||
var descriptor = documents.GetRequired<T>();
|
||||
return new CultNetDocumentBinding(
|
||||
typeof(T),
|
||||
string.IsNullOrWhiteSpace(schemaId) ? descriptor.SchemaId : schemaId!,
|
||||
|
|
@ -89,11 +90,11 @@ namespace GameCult.Networking
|
|||
var typed = (T)document;
|
||||
return payloadSerializer != null
|
||||
? payloadSerializer(typed)
|
||||
: CultDocumentMessagePackSerialization.Serialize(typed);
|
||||
: CultDocumentMessagePackSerialization.SerializeUntyped(typed, typeof(T), documents);
|
||||
},
|
||||
payload => payloadDeserializer != null
|
||||
? payloadDeserializer(payload)
|
||||
: CultDocumentMessagePackSerialization.Deserialize<T>(payload));
|
||||
: (T)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(T), payload, documents));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -111,7 +112,8 @@ namespace GameCult.Networking
|
|||
throw new ArgumentNullException(nameof(documentType));
|
||||
}
|
||||
|
||||
var descriptor = (registry ?? CultDocumentRegistry.Shared).GetRequired(documentType);
|
||||
var documents = registry ?? CultDocumentRegistry.Shared;
|
||||
var descriptor = documents.GetRequired(documentType);
|
||||
return new CultNetDocumentBinding(
|
||||
documentType,
|
||||
string.IsNullOrWhiteSpace(schemaId) ? descriptor.SchemaId : schemaId!,
|
||||
|
|
@ -125,11 +127,11 @@ namespace GameCult.Networking
|
|||
|
||||
return payloadSerializer != null
|
||||
? payloadSerializer(document)
|
||||
: CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType);
|
||||
: CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType, documents);
|
||||
},
|
||||
payload => payloadDeserializer != null
|
||||
? payloadDeserializer(payload)
|
||||
: CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload));
|
||||
: CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, documents));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -711,8 +711,8 @@ namespace GameCult.Caching.Tests
|
|||
{
|
||||
var reference = new CultRecordRef<NamedTestEntry>(new CultRecordKey("record-1"));
|
||||
|
||||
var payload = CultDocumentMessagePackSerialization.Serialize(reference);
|
||||
var roundTrip = CultDocumentMessagePackSerialization.Deserialize<CultRecordRef<NamedTestEntry>>(payload);
|
||||
var payload = MessagePackSerializer.Serialize(reference, CultDocumentMessagePackSerialization.Options);
|
||||
var roundTrip = MessagePackSerializer.Deserialize<CultRecordRef<NamedTestEntry>>(payload, CultDocumentMessagePackSerialization.Options);
|
||||
|
||||
Assert.That(roundTrip.Key.Value, Is.EqualTo("record-1"));
|
||||
}
|
||||
|
|
@ -721,7 +721,7 @@ namespace GameCult.Caching.Tests
|
|||
public void MessagePackSerialization_Rejects_InvalidPayload()
|
||||
{
|
||||
Assert.That(
|
||||
() => CultDocumentMessagePackSerialization.Deserialize<CultRecordRef<NamedTestEntry>>(new byte[] { 0xC1 }),
|
||||
() => MessagePackSerializer.Deserialize<CultRecordRef<NamedTestEntry>>(new byte[] { 0xC1 }, CultDocumentMessagePackSerialization.Options),
|
||||
Throws.TypeOf<MessagePackSerializationException>());
|
||||
}
|
||||
|
||||
|
|
|
|||
253
tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs
Normal file
253
tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
#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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,21 +71,6 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ namespace GameCult.Geometry.Tests
|
|||
{
|
||||
var chunk = SampleChunk();
|
||||
|
||||
var payload = CultDocumentMessagePackSerialization.Serialize(chunk);
|
||||
var decoded = CultDocumentMessagePackSerialization.Deserialize<CultGeometryChunkArtifact>(payload);
|
||||
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(chunk, typeof(CultGeometryChunkArtifact));
|
||||
var decoded = (CultGeometryChunkArtifact)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryChunkArtifact), payload);
|
||||
|
||||
decoded.ChunkId.Should().Be(chunk.ChunkId);
|
||||
decoded.RenderMesh.TriangleCount.Should().Be(1);
|
||||
|
|
@ -150,7 +150,7 @@ namespace GameCult.Geometry.Tests
|
|||
public void RustExportedDomainDocument_DecodesWithStableCultCacheKey()
|
||||
{
|
||||
var payload = File.ReadAllBytes(FixturePath("ragnarok-domain.msgpack"));
|
||||
var domain = CultDocumentMessagePackSerialization.Deserialize<CultGeometryDomainDocument>(payload);
|
||||
var domain = (CultGeometryDomainDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryDomainDocument), payload);
|
||||
|
||||
domain.DomainId.Should().Be("ragnarok-column");
|
||||
domain.Root.Children.Should().NotBeEmpty();
|
||||
|
|
@ -163,7 +163,7 @@ namespace GameCult.Geometry.Tests
|
|||
public void RustExportedChunkArtifact_DecodesWithStableCultCacheKey()
|
||||
{
|
||||
var payload = File.ReadAllBytes(FixturePath("ragnarok-first-chunk.msgpack"));
|
||||
var chunk = CultDocumentMessagePackSerialization.Deserialize<CultGeometryChunkArtifact>(payload);
|
||||
var chunk = (CultGeometryChunkArtifact)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryChunkArtifact), payload);
|
||||
|
||||
chunk.ChunkId.Should().StartWith("chunk/");
|
||||
chunk.RenderMesh.TriangleCount.Should().BeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -2609,12 +2609,12 @@ public sealed class CultMeshStreamingTests
|
|||
var filePath = Path.Combine(directory.Path, "legacy-single-document.ccmp");
|
||||
var key = new CultRecordKey("mesh-note:legacy-single-document");
|
||||
var descriptor = CultDocumentRegistry.Shared.GetRequired<MeshPublicationNoteDocument>();
|
||||
var payload = CultDocumentMessagePackSerialization.Serialize(new MeshPublicationNoteDocument
|
||||
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(new MeshPublicationNoteDocument
|
||||
{
|
||||
Schema = "tests.mesh_publication_note.v1",
|
||||
Text = "legacy-published",
|
||||
Revision = 4
|
||||
});
|
||||
}, typeof(MeshPublicationNoteDocument));
|
||||
|
||||
File.WriteAllBytes(filePath, WriteLegacySingleDocumentSnapshot(
|
||||
key.Value,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Networking;
|
||||
using MessagePack;
|
||||
using MessagePack.Formatters;
|
||||
using NUnit.Framework;
|
||||
|
||||
[assembly: CultCacheFormatterResolver(typeof(GameCult.Networking.Tests.BindingPairResolver))]
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
public class CultNetDocumentBindingResolverTests
|
||||
{
|
||||
[Test]
|
||||
public void TypedBindingUsesTheDocumentAssemblyResolver()
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(BindingPairDocument) });
|
||||
var binding = CultNetDocumentBinding.ForDocument<BindingPairDocument>(registry);
|
||||
|
||||
var payload = binding.PayloadSerializer(new BindingPairDocument { Name = "p", Value = new BindingPair { A = 1.5f, B = -2f } });
|
||||
var decoded = (BindingPairDocument)binding.PayloadDeserializer(payload);
|
||||
|
||||
Assert.That(decoded.Name, Is.EqualTo("p"));
|
||||
Assert.That(decoded.Value.A, Is.EqualTo(1.5f));
|
||||
Assert.That(decoded.Value.B, Is.EqualTo(-2f));
|
||||
}
|
||||
}
|
||||
|
||||
public struct BindingPair
|
||||
{
|
||||
public float A;
|
||||
public float B;
|
||||
}
|
||||
|
||||
[CultDocument("tests.binding_pair", "tests.binding_pair.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class BindingPairDocument
|
||||
{
|
||||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
[Key(1)] public BindingPair Value;
|
||||
}
|
||||
|
||||
public sealed class BindingPairResolver : IFormatterResolver
|
||||
{
|
||||
public static readonly IFormatterResolver Instance = new BindingPairResolver();
|
||||
|
||||
public IMessagePackFormatter<T>? GetFormatter<T>() =>
|
||||
typeof(T) == typeof(BindingPair) ? (IMessagePackFormatter<T>)(object)PairFormatter.Instance : null;
|
||||
|
||||
private sealed class PairFormatter : IMessagePackFormatter<BindingPair>
|
||||
{
|
||||
public static readonly PairFormatter Instance = new();
|
||||
|
||||
public void Serialize(ref MessagePackWriter writer, BindingPair value, MessagePackSerializerOptions options)
|
||||
{
|
||||
writer.WriteArrayHeader(2);
|
||||
writer.Write(value.A);
|
||||
writer.Write(value.B);
|
||||
}
|
||||
|
||||
public BindingPair Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
reader.ReadArrayHeader();
|
||||
return new BindingPair { A = reader.ReadSingle(), B = reader.ReadSingle() };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3102,12 +3102,12 @@ namespace GameCult.Networking.Tests
|
|||
RecordKey = key.Value,
|
||||
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
PayloadEncoding = "messagepack",
|
||||
Payload = CultDocumentMessagePackSerialization.Serialize(new NetworkSchemaNote
|
||||
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(new NetworkSchemaNote
|
||||
{
|
||||
Schema = "tests.networking_note.v1",
|
||||
Text = "payload-routed",
|
||||
Revision = 5
|
||||
})
|
||||
}, typeof(NetworkSchemaNote))
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -3182,7 +3182,7 @@ namespace GameCult.Networking.Tests
|
|||
|
||||
Assert.That(response.Documents, Has.Length.EqualTo(1));
|
||||
Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value));
|
||||
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note)));
|
||||
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -3224,7 +3224,7 @@ namespace GameCult.Networking.Tests
|
|||
|
||||
Assert.That(response.Documents, Has.Length.EqualTo(1));
|
||||
Assert.That(response.Documents[0].RecordKey, Is.EqualTo(key.Value));
|
||||
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note)));
|
||||
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -5645,7 +5645,7 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(message.ChangeKind, Is.EqualTo("added"));
|
||||
Assert.That(message.Document, Is.Not.Null);
|
||||
Assert.That(message.Document!.SchemaId, Is.EqualTo(descriptor.SchemaId));
|
||||
Assert.That(message.Document.Payload, Is.EqualTo(CultDocumentMessagePackSerialization.Serialize(note)));
|
||||
Assert.That(message.Document.Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue