diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 96a4b985..ec0326fb 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -83,8 +83,12 @@ public static class CultDocumentMessagePackSerialization { var generic = GetFormatterMethod.MakeGenericMethod(type); // A resolver that hands back MessagePack's own formatter for the type writes its [Key] layout: that is no claim, and the - // composite stops there, so no later resolver is asked. + // composite stops there, so no later resolver is asked. StandardResolverAllowPrivate's formatter writes the same layout + // plus non-public [Key] members, and registration refuses a non-public member on the way to an object list, so it + // claims nothing either. var standard = generic.Invoke(StandardResolver.Instance, null); + var allowPrivateAsked = false; + object? allowPrivate = null; foreach (var resolver in ahead) { var formatter = generic.Invoke(resolver, null); @@ -92,6 +96,21 @@ public static class CultDocumentMessagePackSerialization continue; if (ReferenceEquals(formatter, standard)) break; + if (!allowPrivateAsked) + { + allowPrivateAsked = true; + try + { + allowPrivate = generic.Invoke(StandardResolverAllowPrivate.Instance, null); + } + catch (Exception) + { + allowPrivate = null; + } + } + + if (allowPrivate != null && ReferenceEquals(formatter, allowPrivate)) + break; return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} formats {type.Name}, which is on the path to a list whose elements carry ids, so its element ids would never reach disk. A resolver may not claim such a type; write it by its [Key] layout or keep it out of the document's lists."; } } diff --git a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs index 9fae36b6..7c98927b 100644 --- a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs +++ b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs @@ -337,6 +337,63 @@ namespace GameCult.Caching.Tests Assert.That(back.Items.Single().Id, Is.EqualTo("000000000009")); } + // A consumer whose resolver composite ends in StandardResolverAllowPrivate (some models keep private [Key] members) gets + // that resolver's formatter for every keyed type. It writes the [Key] layout plus the private members, so it is no + // claim: the document encodes, the id is on disk, and the private value survives. + [Test] + public void AStandardResolverAllowPrivateFormatterIsNoClaim() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(ApDoc), typeof(ApPlainDoc) }); + var doc = new ApDoc { Id = "a1", Items = { new ApArm { Id = "00000000000a", N = 4 }.WithSecret(11) } }; + + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(ApDoc), registry); + + var back = (ApDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(ApDoc), bytes, registry); + Assert.That(back.Items.Single().Id, Is.EqualTo("00000000000a")); + Assert.That(back.Items.Single().Secret, Is.EqualTo(11), "a private plain-value [Key] member is written"); + + var plain = new ApPlainDoc { Id = "a2", Items = { new ApPlainArm { Id = "00000000000b", N = 5 } } }; + var plainBytes = CultDocumentMessagePackSerialization.SerializeUntyped(plain, typeof(ApPlainDoc), registry); + Assert.That(Convert.ToHexString(plainBytes), Is.EqualTo(Convert.ToHexString(MessagePackSerializer.Serialize(plain, CultDocumentMessagePackSerialization.Options)))); + } + + [MessagePackObject] + public sealed class ApArm + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + [Key(2)] private int secret; + [IgnoreMember] public int Secret => secret; + public ApArm WithSecret(int value) + { + secret = value; + return this; + } + } + + [CultDocument("tests.ap_doc", "tests.ap_doc.v1")] + [MessagePackObject] + public sealed class ApDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + [MessagePackObject] + public sealed class ApPlainArm + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + } + + [CultDocument("tests.ap_plain_doc", "tests.ap_plain_doc.v1")] + [MessagePackObject] + public sealed class ApPlainDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + [MessagePackObject] public sealed class LcArm { @@ -400,6 +457,9 @@ namespace GameCult.Caching.Tests if (typeof(T) == typeof(List)) return (IMessagePackFormatter)(object)new ValuesFormatter(); if (typeof(T) == typeof(List)) return (IMessagePackFormatter)(object)new PointsFormatter(); if (typeof(T) == typeof(FbArm)) return global::MessagePack.Resolvers.StandardResolver.Instance.GetFormatter(); + if (typeof(T) == typeof(ApArm) || typeof(T) == typeof(ApDoc) || typeof(T) == typeof(List) || + typeof(T) == typeof(ApPlainArm) || typeof(T) == typeof(ApPlainDoc) || typeof(T) == typeof(List)) + return global::MessagePack.Resolvers.StandardResolverAllowPrivate.Instance.GetFormatter(); return null; }