diff --git a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs index 2ffe63ae..ad7f934e 100644 --- a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs +++ b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs @@ -50,8 +50,8 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator return null; } - var unkeyedMembers = new List(); - var members = DiscoverMembers(typeSymbol, unkeyedMembers); + var rejections = new List(); + var members = DiscoverMembers(typeSymbol, rejections); var nameMember = members.FirstOrDefault(member => member.IsName); var canConstructForDeserialization = typeSymbol.TypeKind == TypeKind.Struct || typeSymbol.InstanceConstructors.Any(constructor => @@ -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 DiscoverMembers(INamedTypeSymbol typeSymbol, List 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 DiscoverMembers(INamedTypeSymbol typeSymbol, List rejections) { - var shaped = new List(); - 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(); + var seenRoots = new HashSet(SymbolEqualityComparer.Default); + var depth = 0; + for (var type = typeSymbol; type != null && type.SpecialType != SpecialType.System_Object; type = type.BaseType, depth++) { foreach (var member in type.GetMembers()) { - 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 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 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 members, bool canConstructForDeserialization, bool hasDenseSlots, - ImmutableArray unkeyedMemberMessages, + ImmutableArray rejections, Location? location) { - UnkeyedMemberMessages = unkeyedMemberMessages; + Rejections = rejections; Location = location; DocumentTypeName = documentTypeName; SchemaName = schemaName; @@ -566,7 +653,7 @@ public sealed class CultDocumentMessagePackGenerator : IIncrementalGenerator public ImmutableArray Members { get; } public bool CanConstructForDeserialization { get; } public bool HasDenseSlots { get; } - public ImmutableArray UnkeyedMemberMessages { get; } + public ImmutableArray Rejections { get; } public Location? Location { get; } } diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index b2e40fef..78e3eabf 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -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 value) - { - return MessagePackSerializer.Serialize(value, Options); - } - - public static T Deserialize(byte[] payload) - { - return MessagePackSerializer.Deserialize(payload, Options); - } - public static byte[] SerializeUntyped(object value, Type type) { return SerializeUntyped(value, type, CultDocumentRegistry.Shared); diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 70d8530d..5a911bd2 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -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 DiscoverMembers(Type type) { - var members = new List(); - - 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(); + 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 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; } } diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 305fed4a..d40eef03 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -1023,7 +1023,7 @@ namespace GameCult.Mesh } else { - documents.Add(CultDocumentMessagePackSerialization.Deserialize(record.Payload)); + documents.Add((TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload)); } } diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index cc4b319a..4a5d314e 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -80,7 +80,8 @@ namespace GameCult.Networking Func? payloadDeserializer = null) where T : class { - var descriptor = (registry ?? CultDocumentRegistry.Shared).GetRequired(); + var documents = registry ?? CultDocumentRegistry.Shared; + var descriptor = documents.GetRequired(); 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(payload)); + : (T)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(T), payload, documents)); } /// @@ -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)); } } diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 85fc1d3f..332d4081 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -711,8 +711,8 @@ namespace GameCult.Caching.Tests { var reference = new CultRecordRef(new CultRecordKey("record-1")); - var payload = CultDocumentMessagePackSerialization.Serialize(reference); - var roundTrip = CultDocumentMessagePackSerialization.Deserialize>(payload); + var payload = MessagePackSerializer.Serialize(reference, CultDocumentMessagePackSerialization.Options); + var roundTrip = MessagePackSerializer.Deserialize>(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>(new byte[] { 0xC1 }), + () => MessagePackSerializer.Deserialize>(new byte[] { 0xC1 }, CultDocumentMessagePackSerialization.Options), Throws.TypeOf()); } diff --git a/tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs b/tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs new file mode 100644 index 00000000..12d6e70d --- /dev/null +++ b/tests/GameCult.Caching.Tests/DocumentShapeSweepTests.cs @@ -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 Tags = new List(); + [Key(4)] public Dictionary Counts { get; set; } = new Dictionary(); + [IgnoreMember] public int Skip { get; set; } +} +public static class Samples +{ + public static object Plain() => new Plain { Name = ""p"", Maybe = 7, Tint = Color.Blue, Tags = { ""a"" }, Counts = { [""x""] = 1 }, Skip = 9 }; +}", "Plain"); + + [Test] + public void DerivedDocument() => Accept(@" +[CultDocument(""sweep.gear"", ""sweep.gear.v1"")] +[MessagePackObject] +public class Gear { [Key(0)] public string Name { get; set; } = """"; } +[CultDocument(""sweep.weapon"", ""sweep.weapon.v1"")] +[MessagePackObject] +public sealed class Weapon : Gear { [Key(1)] public int Damage { get; set; } } +public static class Samples +{ + public static object Gear() => new Gear { Name = ""helm"" }; + public static object Weapon() => new Weapon { Name = ""lance"", Damage = 4 }; +}", "Gear", "Weapon"); + + [Test] + public void NameAndIndexOnABaseSharedByTwoDocuments() => Accept(@" +[CultDocument(""sweep.gear"", ""sweep.gear.v1"")] +[MessagePackObject] +public class Gear +{ + [Key(0)] [CultName] public string Name { get; set; } = """"; + [Key(1)] [CultIndex(""slot"")] public string Slot { get; set; } = """"; +} +[CultDocument(""sweep.weapon"", ""sweep.weapon.v1"")] +[MessagePackObject] +public sealed class Weapon : Gear { [Key(2)] public int Damage { get; set; } } +public static class Samples +{ + public static object Gear() => new Gear { Name = ""helm"", Slot = ""head"" }; + public static object Weapon() => new Weapon { Name = ""lance"", Slot = ""hand"", Damage = 4 }; +}", "Gear", "Weapon"); + + [Test] + public void OverrideKeepsBaseKey() + { + var payloads = Accept(@" +[MessagePackObject] +public class OvrSameBase { [Key(0)] public virtual string Name { get; set; } = """"; [Key(2)] public virtual int Z { get; set; } } +[CultDocument(""sweep.ovr_same"", ""sweep.ovr_same.v1"")] +[MessagePackObject] +public sealed class OvrSame : OvrSameBase +{ + [Key(0)] public override string Name { get; set; } = """"; + public override int Z { get; set; } + [Key(1)] public int X { get; set; } +} +public static class Samples +{ + public static object OvrSame() => new OvrSame { Name = ""n"", X = 3, Z = 5 }; +}", "OvrSame"); + + // Captured from MessagePack's DynamicObjectResolver: [Name, X, Z]. + Assert.That(Convert.ToHexString(payloads["OvrSame"]), Is.EqualTo("93A16E0305")); + } + + // MessagePack's DynamicObjectResolver reads the base declaration: it throws "key is duplicated" for this shape. + [Test] + public void OverrideReKeyed() => Reject(@" +[MessagePackObject] +public class OvrDiffBase { [Key(0)] public virtual string Name { get; set; } = """"; } +[CultDocument(""sweep.ovr_diff"", ""sweep.ovr_diff.v1"")] +[MessagePackObject] +public sealed class OvrDiff : OvrDiffBase +{ + [Key(2)] public override string Name { get; set; } = """"; + [Key(1)] public int X { get; set; } + [Key(0)] public int Y { get; set; } +}", + "OvrDiff", + "Cult document OvrDiff member OvrDiff.Name overrides OvrDiffBase.Name with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them."); + + // MessagePack's DynamicObjectResolver keeps the base [Key(1)] and serializes Name despite the override's [IgnoreMember]. + [Test] + public void OverrideIgnored() => Reject(@" +[MessagePackObject] +public class OvrIgnoreBase { [Key(1)] public virtual string Name { get; set; } = """"; } +[CultDocument(""sweep.ovr_ignore"", ""sweep.ovr_ignore.v1"")] +[MessagePackObject] +public sealed class OvrIgnore : OvrIgnoreBase +{ + [IgnoreMember] public override string Name { get; set; } = """"; + [Key(0)] public int X { get; set; } +}", + "OvrIgnore", + "Cult document OvrIgnore member OvrIgnore.Name overrides OvrIgnoreBase.Name with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them."); + + [Test] + public void NewHiddenField() => Reject(@" +public class NewFieldBase { [Key(0)] public string Name = ""base""; } +[CultDocument(""sweep.new_field"", ""sweep.new_field.v1"")] +[MessagePackObject] +public sealed class NewField : NewFieldBase { [Key(0)] public new string Name = """"; [Key(1)] public int X; }", + "NewField", + "Cult document NewField members NewField.Name and NewFieldBase.Name share [Key(0)]; every persisted member needs a distinct [Key(n)]."); + + [Test] + public void NewHiddenProperty() => Reject(@" +public class NewPropBase { [Key(0)] public string Name { get; set; } = ""base""; } +[CultDocument(""sweep.new_prop"", ""sweep.new_prop.v1"")] +[MessagePackObject] +public sealed class NewProp : NewPropBase { [Key(0)] public new string Name { get; set; } = """"; [Key(1)] public int X { get; set; } }", + "NewProp", + "Cult document NewProp members NewProp.Name and NewPropBase.Name share [Key(0)]; every persisted member needs a distinct [Key(n)]."); + + [Test] + public void DuplicateSlot() => Reject(@" +[CultDocument(""sweep.dup"", ""sweep.dup.v1"")] +[MessagePackObject] +public sealed class Dup { [Key(0)] public int B; [Key(0)] public int A; }", + "Dup", + "Cult document Dup members Dup.A and Dup.B share [Key(0)]; every persisted member needs a distinct [Key(n)]."); + + [Test] + public void InheritedPrivateSetter() => Reject(@" +public class PrivSetBase { [Key(0)] public string Name { get; private set; } = ""fixed""; } +[CultDocument(""sweep.privset"", ""sweep.privset.v1"")] +[MessagePackObject] +public sealed class PrivSetDerived : PrivSetBase { [Key(1)] public int X { get; set; } }", + "PrivSetDerived", + "Cult document PrivSetDerived member Name is not writable; a persisted member needs a non-readonly field or a public or internal set accessor."); + + [Test] + public void StringKey() => Reject(@" +[CultDocument(""sweep.string_key"", ""sweep.string_key.v1"")] +[MessagePackObject] +public sealed class StringKeyed { [Key(""name"")] public string Name { get; set; } = """"; }", + "StringKeyed", + "Cult document StringKeyed member Name has a string [Key]; string keys are not supported; use integer [Key(n)]."); + + private static Dictionary Accept(string source, params string[] documentNames) + { + var assembly = Compile(source, out var generatorErrors); + Assert.That(generatorErrors, Is.Empty); + var samples = assembly.GetType("Samples")!; + var payloads = new Dictionary(); + foreach (var name in documentNames) + { + var type = assembly.GetType(name)!; + var sample = samples.GetMethod(name)!.Invoke(null, null)!; + var generated = CultDocumentRegistry.ForTypes(new[] { type }).GetRequired(type); + var reflective = CultDocumentRegistry.BuildDescriptor(type); + Assert.That(generated.GeneratedPayloadSerializer, Is.Not.Null, $"{name}: no generated codec"); + Assert.That(generated.GeneratedPayloadDeserializer, Is.Not.Null, $"{name}: no generated decoder"); + + var options = CultDocumentMessagePackSerialization.OptionsFor(assembly); + var dynamicBytes = MessagePackSerializer.Serialize(type, sample, options); + var generatedBytes = generated.GeneratedPayloadSerializer!(sample); + var decoded = generated.GeneratedPayloadDeserializer!(generatedBytes); + Assert.Multiple(() => + { + Assert.That(generated.SchemaId, Is.EqualTo(reflective.SchemaId), $"{name}: schema id"); + Assert.That(generated.CanonicalSchemaJson, Is.EqualTo(reflective.CanonicalSchemaJson), $"{name}: schema json"); + Assert.That(Catalog(generated), Is.EqualTo(Catalog(reflective)), $"{name}: catalog members"); + Assert.That(generated.NameMember, Is.EqualTo(reflective.NameMember), $"{name}: name member"); + Assert.That(generated.NameAccessor?.Invoke(sample), Is.EqualTo(reflective.NameAccessor?.Invoke(sample)), $"{name}: name value"); + Assert.That(Indexes(generated, sample), Is.EqualTo(Indexes(reflective, sample)), $"{name}: index values"); + Assert.That(Convert.ToHexString(generatedBytes), Is.EqualTo(Convert.ToHexString(dynamicBytes)), $"{name}: payload"); + Assert.That(Convert.ToHexString(generated.GeneratedPayloadSerializer(decoded)), Is.EqualTo(Convert.ToHexString(generatedBytes)), $"{name}: round trip"); + Assert.That(Convert.ToHexString(generated.GeneratedPayloadSerializer(MessagePackSerializer.Deserialize(type, dynamicBytes, options)!)), + Is.EqualTo(Convert.ToHexString(dynamicBytes)), $"{name}: dynamic round trip"); + }); + payloads[name] = generatedBytes; + } + + return payloads; + } + + private static void Reject(string source, string documentName, string expected) + { + var assembly = Compile(source, out var generatorErrors); + var reflective = Assert.Throws(() => CultDocumentRegistry.BuildDescriptor(assembly.GetType(documentName)!))!; + Assert.Multiple(() => + { + Assert.That(generatorErrors.FirstOrDefault(), Is.EqualTo(expected), "generator"); + Assert.That(reflective.Message, Is.EqualTo(expected), "registry"); + }); + } + + private static string[] Catalog(CultDocumentDescriptor descriptor) => + descriptor.ToCatalogEntry().Members + .Select(member => $"{member.Slot}:{member.MemberName}:{member.TypeName}:{member.IsReference}:{member.IsMany}:{member.TargetSchemaName}:{member.IsName}:{member.IndexAlias}") + .ToArray(); + + private static string[] Indexes(CultDocumentDescriptor descriptor, object sample) => + descriptor.IndexAccessors.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value(sample)}").ToArray(); + + private static Assembly Compile(string source, out string[] generatorErrors) + { + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Append(typeof(CultDocumentAttribute).Assembly.Location) + .Append(typeof(CultDocumentMessagePackSerialization).Assembly.Location) + .Append(typeof(KeyAttribute).Assembly.Location) + .Append(typeof(MessagePackSerializer).Assembly.Location) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(path => MetadataReference.CreateFromFile(path)); + var compilation = CSharpCompilation.Create( + "Sweep_" + Guid.NewGuid().ToString("N"), + new[] { CSharpSyntaxTree.ParseText(Prelude + source) }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + CSharpGeneratorDriver.Create(new CultDocumentMessagePackGenerator()) + .RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + generatorErrors = diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Select(diagnostic => diagnostic.GetMessage()).ToArray(); + + using var stream = new MemoryStream(); + var emitted = output.Emit(stream); + Assert.That(emitted.Diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).Select(diagnostic => diagnostic.ToString()), Is.Empty, "compile"); + return Assembly.Load(stream.ToArray()); + } + } +} diff --git a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs index 9598d4d8..31619292 100644 --- a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs +++ b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs @@ -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(); - 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() { diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index 16e66b8e..302f53f3 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -121,8 +121,8 @@ namespace GameCult.Geometry.Tests { var chunk = SampleChunk(); - var payload = CultDocumentMessagePackSerialization.Serialize(chunk); - var decoded = CultDocumentMessagePackSerialization.Deserialize(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(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(payload); + var chunk = (CultGeometryChunkArtifact)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryChunkArtifact), payload); chunk.ChunkId.Should().StartWith("chunk/"); chunk.RenderMesh.TriangleCount.Should().BeGreaterThan(0); diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index da3851df..584569e6 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -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(); - 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, diff --git a/tests/GameCult.Networking.Tests/CultNetDocumentBindingResolverTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentBindingResolverTests.cs new file mode 100644 index 00000000..72af09c9 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetDocumentBindingResolverTests.cs @@ -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(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? GetFormatter() => + typeof(T) == typeof(BindingPair) ? (IMessagePackFormatter)(object)PairFormatter.Instance : null; + + private sealed class PairFormatter : IMessagePackFormatter + { + 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() }; + } + } + } +} diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 6e915984..756e55a3 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -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]