diff --git a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs index 4fa55076..8d0c7c96 100644 --- a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs +++ b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs @@ -23,10 +23,10 @@ namespace GameCult.Caching.MessagePack // options, resolvers included, so dictionary keys compare by the bytes the store writes; a document type // deserializes as a registered document, a member type as itself. public static CultCodec CreateCodec(CultDocumentRegistry registry) => new CultCodec( - (value, type, document) => global::MessagePack.MessagePackSerializer.Serialize(type, value, CultDocumentMessagePackSerialization.OptionsFor(document.Assembly)), + (value, type, document) => global::MessagePack.MessagePackSerializer.Serialize(type, value, CultDocumentMessagePackSerialization.OptionsForDocument(document)), (type, document, bytes) => type == document ? CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, registry) - : global::MessagePack.MessagePackSerializer.Deserialize(type, bytes, CultDocumentMessagePackSerialization.OptionsFor(document.Assembly)), + : global::MessagePack.MessagePackSerializer.Deserialize(type, bytes, CultDocumentMessagePackSerialization.OptionsForDocument(document)), CultDocumentMessagePackSerialization.OverlaySlots); // The inspection model over that codec. diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index a10a0d62..cba6fdea 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -38,11 +38,23 @@ public static class CultDocumentMessagePackSerialization private const int SchemaCatalogMemberFieldCount = 8; private const int StoreSnapshotFieldCount = 3; - public static readonly MessagePackSerializerOptions Options = Compose(Array.Empty()); + private static readonly (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Plain = Compose(Array.Empty()); - private static readonly ConcurrentDictionary AssemblyOptions = new(); + public static readonly MessagePackSerializerOptions Options = Plain.Options; - public static MessagePackSerializerOptions OptionsFor(Assembly documentAssembly) + // Per assembly: the options a document type's bytes are written with, and the resolvers composed ahead of + // StandardResolver, the ones that can claim a type before its [Key] layout does. + private static readonly ConcurrentDictionary AssemblyOptions = new(); + + // Per document type: why its element ids cannot reach disk under the composed resolvers, or null. Types and resolvers + // are fixed for the process, so this is a cache of a pure answer. + private static readonly ConcurrentDictionary DocumentProblems = new(); + + private static readonly MethodInfo GetFormatterMethod = typeof(IFormatterResolver).GetMethod(nameof(IFormatterResolver.GetFormatter))!; + + public static MessagePackSerializerOptions OptionsFor(Assembly documentAssembly) => ComposedFor(documentAssembly).Options; + + private static (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) ComposedFor(Assembly documentAssembly) { if (documentAssembly == null) throw new ArgumentNullException(nameof(documentAssembly)); return AssemblyOptions.GetOrAdd(documentAssembly, static assembly => @@ -50,19 +62,46 @@ public static class CultDocumentMessagePackSerialization var resolvers = assembly.GetCustomAttributes() .Select(attribute => CreateResolver(attribute.ResolverType)) .ToArray(); - return resolvers.Length == 0 ? Options : Compose(resolvers); + return resolvers.Length == 0 ? Plain : Compose(resolvers); }); } - private static MessagePackSerializerOptions Compose(IFormatterResolver[] consumerResolvers) + // The options a document's bytes are written and read with. A document's list elements carry their ids by their [Key] + // layout, so a resolver ahead of StandardResolver that formats a type on the way to those lists would write them + // without the id: refused here, once per document type, on every encode and decode path. + internal static MessagePackSerializerOptions OptionsForDocument(Type documentType) + { + if (documentType == null) throw new ArgumentNullException(nameof(documentType)); + var (options, ahead) = ComposedFor(documentType.Assembly); + var problem = DocumentProblems.GetOrAdd(documentType, type => ResolverClaim(type, ahead)); + return problem == null ? options : throw new InvalidOperationException(problem); + } + + private static string? ResolverClaim(Type documentType, IFormatterResolver[] ahead) + { + foreach (var type in CultElementIds.Inspect(documentType).IdPath) + { + var generic = GetFormatterMethod.MakeGenericMethod(type); + foreach (var resolver in ahead) + { + if (generic.Invoke(resolver, null) != null) + 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."; + } + } + + return null; + } + + private static (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Compose(IFormatterResolver[] consumerResolvers) { // The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer // resolver still encodes an arm while the key check runs. - var inner = CompositeResolver.Create( - consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).Append(StandardResolver.Instance).ToArray()); - return MessagePackSerializerOptions.Standard + var ahead = consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).ToArray(); + var inner = CompositeResolver.Create(ahead.Append(StandardResolver.Instance).ToArray()); + var options = MessagePackSerializerOptions.Standard .WithResolver(CompositeResolver.Create(new CultUnionGuard(inner), inner)) .WithSecurity(CultMessagePackSecurity.Instance); + return (options, ahead); } private static IFormatterResolver CreateResolver(Type resolverType) @@ -84,7 +123,7 @@ public static class CultDocumentMessagePackSerialization { if (registry == null) throw new ArgumentNullException(nameof(registry)); if (value != null) registry.GetRequired(type); - var payload = MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly)); + var payload = MessagePackSerializer.Serialize(type, value, OptionsForDocument(type)); CultPayloadDepth.Check(payload, type, PayloadDirection.Write); return payload; } @@ -99,7 +138,7 @@ public static class CultDocumentMessagePackSerialization if (registry == null) throw new ArgumentNullException(nameof(registry)); registry.GetRequired(type); CultPayloadDepth.Check(payload, type, PayloadDirection.Read); - return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type.Assembly)) + return MessagePackSerializer.Deserialize(type, payload, OptionsForDocument(type)) ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); } diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index b03627ca..32a92c43 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -294,6 +294,10 @@ exclusive open of the same lock file exclude each other is not established. for `X` that `Doc` needs is declared in `Doc`'s assembly. - An assembly that declares nothing, and the store envelope, use the base options. +- A resolver may not claim a type a document's element lists pass through: the list's + element types, the types and members on the way to them. Those are written by their + `[Key]` layout so each element's id reaches disk, and the codec refuses the document + on first encode or decode, naming the resolver and the type. - Deserialization runs under untrusted-data security. `CultRecordRef` is a permitted dictionary key, compared by its key string. diff --git a/src/GameCult.Caching/CultElementIds.cs b/src/GameCult.Caching/CultElementIds.cs index 8e10198b..87223289 100644 --- a/src/GameCult.Caching/CultElementIds.cs +++ b/src/GameCult.Caching/CultElementIds.cs @@ -95,34 +95,52 @@ namespace GameCult.Caching internal sealed class Survey { public List Problems { get; } = new(); + + // Every type whose bytes carry an element id: the types a walk descends through to reach an object list, the list's + // element types and every concrete type under them. A serialization codec must write all of these by their [Key] + // layout, so it checks them against the formatters it composed. + public List IdPath { get; } = new(); + } + + // What the walk saw, settled once it is complete: a type reaches a list only through types reached later, and a type + // can sit in a cycle, so the path is a fixed point over these edges and not a stack. + private sealed class Graph + { + public List<(Type Owner, MemberInfo? Member, Type Child)> Edges { get; } = new(); + public List<(Type Owner, MemberInfo Member, Type Element, List Concrete)> Lists { get; } = new(); } // Every reason this document type cannot be registered under the id rule, one per offending concrete element type. internal static Survey Inspect(Type documentType) { var survey = new Survey(); - VisitObject(documentType, documentType, new HashSet(), survey); + var graph = new Graph(); + VisitObject(documentType, documentType, new HashSet(), survey, graph); + Settle(documentType, survey, graph); return survey; } - private static void VisitObject(Type documentType, Type type, HashSet seen, Survey survey) + private static void VisitObject(Type documentType, Type type, HashSet seen, Survey survey, Graph graph) { if (!seen.Add(type)) return; foreach (var (member, memberType) in KeyedMembers(type)) - VisitMemberType(documentType, type, member, memberType, seen, survey); + VisitMemberType(documentType, type, member, memberType, seen, survey, graph); foreach (var union in type.GetCustomAttributes(true)) - VisitObject(documentType, union.SubType, seen, survey); + { + graph.Edges.Add((type, null, union.SubType)); + VisitObject(documentType, union.SubType, seen, survey, graph); + } } - private static void VisitMemberType(Type documentType, Type owner, MemberInfo member, Type memberType, HashSet seen, Survey survey) + private static void VisitMemberType(Type documentType, Type owner, MemberInfo member, Type memberType, HashSet seen, Survey survey, Graph graph) { memberType = Nullable.GetUnderlyingType(memberType) ?? memberType; if (IsLeaf(memberType)) return; if (DictionaryValueType(memberType) is { } valueType) { - VisitMemberType(documentType, owner, member, valueType, seen, survey); + VisitMemberType(documentType, owner, member, valueType, seen, survey, graph); return; } @@ -130,22 +148,80 @@ namespace GameCult.Caching { if (IsObjectType(element)) { - foreach (var concrete in ConcreteTypes(element)) + var concretes = ConcreteTypes(element).ToList(); + graph.Lists.Add((owner, member, element, concretes)); + foreach (var concrete in concretes) { - var problem = IdProblem(concrete); + var problem = IsObjectType(concrete) ? IdProblem(concrete) : UnkeyedElementProblem; if (problem != null) survey.Problems.Add($"Cult document {documentType.Name}: list element type {concrete.Name} (in {owner.Name}.{member.Name}) {problem}"); } - VisitObject(documentType, element, seen, survey); + VisitObject(documentType, element, seen, survey, graph); } else - VisitMemberType(documentType, owner, member, element, seen, survey); + VisitMemberType(documentType, owner, member, element, seen, survey, graph); return; } if (IsObjectType(memberType)) - VisitObject(documentType, memberType, seen, survey); + { + graph.Edges.Add((owner, member, memberType)); + VisitObject(documentType, memberType, seen, survey, graph); + } + } + + private const string UnkeyedElementProblem = + "is written by a formatter the cache does not own, so its element id would never reach disk; mark it [MessagePackObject] with [Key] members, or keep it out of lists."; + + // The id path: every type that reaches an object list, the list's element types and the concrete types under them. + // [MessagePackFormatter] on any of them, or on a member the walk descends through, would hand the bytes to a formatter + // that does not write the [Key] layout the id lives in. + private static void Settle(Type documentType, Survey survey, Graph graph) + { + var reaching = new HashSet(graph.Lists.Select(list => list.Owner)); + var members = new List(graph.Lists.Select(list => list.Member)); + for (var grew = true; grew;) + { + grew = false; + foreach (var (owner, member, child) in graph.Edges) + { + if (!reaching.Contains(child)) + continue; + if (member != null && !members.Contains(member)) + members.Add(member); + grew |= reaching.Add(owner); + } + } + + var path = new List(); + void Add(Type type) + { + if (!path.Contains(type)) + path.Add(type); + } + + foreach (var type in reaching) + Add(type); + foreach (var list in graph.Lists) + { + Add(list.Element); + foreach (var concrete in list.Concrete) + Add(concrete); + } + + foreach (var type in path) + { + survey.IdPath.Add(type); + if (type.IsDefined(typeof(MessagePackFormatterAttribute), true)) + survey.Problems.Add($"Cult document {documentType.Name}: {type.Name} is on the path to a list whose elements carry ids and declares [MessagePackFormatter], so its element ids would never reach disk; remove the attribute or keep this type out of the document's lists."); + } + + foreach (var member in members) + { + if (member.IsDefined(typeof(MessagePackFormatterAttribute), true)) + survey.Problems.Add($"Cult document {documentType.Name}: {member.DeclaringType!.Name}.{member.Name} leads to a list whose elements carry ids and declares [MessagePackFormatter], so its element ids would never reach disk; remove the attribute."); + } } // The types an element can actually be: the element type itself when it can be instantiated, and every union subtype @@ -177,6 +253,8 @@ namespace GameCult.Caching if (ids.Length > 1) return $"has two [CultElementId] members ({ids[0].Name}, {ids[1].Name}); an element has one identity."; var id = ids[0]; + if (id.IsDefined(typeof(MessagePackFormatterAttribute), true)) + return $"declares [MessagePackFormatter] on its [CultElementId] member {id.Name}, so the id would not be written by its [Key]."; if (TypeOf(id) != typeof(string)) return $"declares [CultElementId] on {id.Name}, which is not a string."; if (KeyOf(id) is not { } idKey) diff --git a/tests/GameCult.Caching.Tests/AssemblyInfo.cs b/tests/GameCult.Caching.Tests/AssemblyInfo.cs index 0dd8b125..03e1780f 100644 --- a/tests/GameCult.Caching.Tests/AssemblyInfo.cs +++ b/tests/GameCult.Caching.Tests/AssemblyInfo.cs @@ -6,6 +6,7 @@ using MessagePack.Formatters; [assembly: CultCacheFormatterResolver(typeof(PairResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.StubArmResolver))] +[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ClaimArmResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.OrderResolver))] namespace GameCult.Caching.Tests diff --git a/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs b/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs index 7ae2ee20..bd9ddacd 100644 --- a/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs @@ -290,12 +290,71 @@ namespace GameCult.Caching.Tests Assert.That(Assert.Throws(() => Assign(twice, "r", deterministic: false))!.Message, Does.Contain("'5'")); } + // A list element is written by its [Key] layout or its id never reaches disk. A union arm without [MessagePackObject] + // is written by some other formatter; a list of a type that is not keyed at all is a value list, replaced whole. + [Test] + public void ANonKeyedArmOfAListUnionIsRefusedAtRegistration() + { + var problem = Problems(typeof(HUnkeyedArmList)).Single(); + Assert.That(problem, Does.Contain("UnkeyedArm").And.Contain("formatter the cache does not own").And.Contain("HUnkeyedArmList")); + Assert.That(Problems(typeof(HUnkeyedArmField)), Is.Empty, "the same union as a field has no element id to lose"); + Assert.That(Problems(typeof(HValueList)), Is.Empty, "a list of a non-keyed type is a value list"); + } + + [Test] + public void AFormatterAttributeOnTheIdPathIsRefused() + { + Assert.That(Problems(typeof(HFormattedElement)).Single(), Does.Contain("FormattedElement").And.Contain("[MessagePackFormatter]")); + Assert.That(Problems(typeof(HFormattedList)).Single(), Does.Contain("Items").And.Contain("[MessagePackFormatter]")); + Assert.That(Problems(typeof(HFormattedId)).Single(), Does.Contain("Id").And.Contain("[MessagePackFormatter]")); + Assert.That(Problems(typeof(HFormattedOwner)).Single(), Does.Contain("FormattedOwner").And.Contain("[MessagePackFormatter]")); + Assert.That(Problems(typeof(HFormattedMember)).Single(), Does.Contain("Inner").And.Contain("[MessagePackFormatter]")); + Assert.That(Problems(typeof(HFormattedField)), Is.Empty, "a formatted type with no list below it is a field"); + } + + // The id path is a fixed point, not the stack at the moment a list is found: a type in a cycle with the list's owner, and + // a type reached twice, are both on the way to it. + [Test] + public void TheIdPathHoldsEveryTypeThatReachesAnObjectList() + { + var path = CultElementIds.Inspect(typeof(HDiamond)).IdPath; + Assert.That(path, Is.EquivalentTo(new[] { typeof(HDiamond), typeof(HLeft), typeof(HRight), typeof(HCycle), typeof(HCycleBack), typeof(EIded) })); + Assert.That(CultElementIds.Inspect(typeof(HFine)).IdPath, Is.EquivalentTo(new[] { typeof(HFine), typeof(ENest), typeof(EIded) })); + Assert.That(CultElementIds.Inspect(typeof(HFormattedField)).IdPath, Is.Empty); + } + // ---- shapes ---- [MessagePackObject] public class EBare { [Key(0)] public string X { get; set; } = ""; } [MessagePackObject] public class EIded { [Key(0)] public string X { get; set; } = ""; [Key(1)] [CultElementId] public string Id { get; set; } = ""; } [MessagePackObject] public class ENest { [Key(0)] [CultElementId] public string Id { get; set; } = ""; [Key(1)] public List Sub { get; set; } = new(); } [MessagePackObject] public class EBareNest { [Key(0)] [CultElementId] public string Id { get; set; } = ""; [Key(1)] public List Sub { get; set; } = new(); } + public sealed class NoFormatter { } + public class UnkeyedArm : ArmBase { [Key(0)] public string Id { get; set; } = ""; } + [Union(0, typeof(KeyedArm)), Union(1, typeof(UnkeyedArm))] public abstract class ArmBase { } + [MessagePackObject] public class KeyedArm : ArmBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HUnkeyedArmList { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HUnkeyedArmField { [Key(0)] public ArmBase? Root { get; set; } } + public class PlainValue { public string X { get; set; } = ""; } + [MessagePackObject] public class HValueList { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] [MessagePackFormatter(typeof(NoFormatter))] public class FormattedElement { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HFormattedElement { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HFormattedList { [Key(0)] [MessagePackFormatter(typeof(NoFormatter))] public List Items { get; set; } = new(); } + [MessagePackObject] public class FormattedIdElement { [Key(0)] [CultElementId] [MessagePackFormatter(typeof(NoFormatter))] public string Id { get; set; } = ""; } + [MessagePackObject] public class HFormattedId { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] [MessagePackFormatter(typeof(NoFormatter))] public class FormattedOwner { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HFormattedOwner { [Key(0)] public FormattedOwner Owner { get; set; } = new(); } + [MessagePackObject] public class HFormattedMember { [Key(0)] [MessagePackFormatter(typeof(NoFormatter))] public HHasIds Inner { get; set; } = new(); } + [MessagePackObject] [MessagePackFormatter(typeof(NoFormatter))] public class FormattedLeaf { [Key(0)] public int N { get; set; } } + [MessagePackObject] public class HFormattedField { [Key(0)] public FormattedLeaf Leaf { get; set; } = new(); [Key(1)] public List Tags { get; set; } = new(); } + + [MessagePackObject] public class HLeft { [Key(0)] public HCycle Cycle { get; set; } = new(); } + [MessagePackObject] public class HRight { [Key(0)] public HCycle Cycle { get; set; } = new(); } + [MessagePackObject] public class HCycle { [Key(0)] public HCycleBack Back { get; set; } = new(); } + [MessagePackObject] public class HCycleBack { [Key(0)] public HCycle? Up { get; set; } [Key(1)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HDiamond { [Key(0)] public HLeft Left { get; set; } = new(); [Key(1)] public HRight Right { get; set; } = new(); } + [MessagePackObject] public class HList { [Key(0)] public List Items { get; set; } = new(); } [MessagePackObject] public class HArray { [Key(0)] public EBare[] Items { get; set; } = Array.Empty(); } [MessagePackObject] public class HDictionary { [Key(0)] public Dictionary Map { get; set; } = new(); } diff --git a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs index a727ba75..21fe44cf 100644 --- a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs +++ b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs @@ -20,7 +20,7 @@ namespace GameCult.Caching.Tests { private const string Canary = "CANARY-9d1e47"; - private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(GuardDoc), typeof(ShapeDoc) }); + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(GuardDoc), typeof(ShapeDoc), typeof(StubDoc) }); private delegate void Emit(ref MessagePackWriter writer); @@ -246,15 +246,89 @@ namespace GameCult.Caching.Tests [Test] public void ConsumerResolverStillEncodesArms() { - var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(new GuardDoc { Id = "d1", Root = new StubArm() }, typeof(GuardDoc), Registry); - Assert.That(Convert.ToHexString(bytes), Is.EqualTo(Convert.ToHexString(Doc((ref MessagePackWriter w) => + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(new StubDoc { Id = "d1", Root = new StubArm() }, typeof(StubDoc), Registry); + Assert.That(Convert.ToHexString(bytes), Is.EqualTo(Convert.ToHexString(Raw((ref MessagePackWriter w) => { + w.WriteArrayHeader(2); w.Write("d1"); w.WriteArrayHeader(2); w.Write(9); w.WriteArrayHeader(1); w.Write("via-resolver"); })))); - var back = (GuardDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(GuardDoc), bytes, Registry); + var back = (StubDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(StubDoc), bytes, Registry); Assert.That(back.Root, Is.TypeOf()); } + // A list element is written by its [Key] layout or its id never reaches disk: a consumer resolver that claims the arm of + // a list's union is refused on every path that encodes or decodes the document, naming the arm and the resolver, and + // writes nothing. + [Test] + public void ListElementsClaimedByAResolverAreRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(ClaimDoc) }); + var doc = new ClaimDoc { Id = "c1", Items = { new ClaimArm { N = 4 } } }; + void Refused(TestDelegate attempt) + { + var thrown = Assert.Throws(attempt)!; + Assert.That(thrown.Message, Does.Contain("ClaimArm").And.Contain(nameof(ClaimArmResolver)).And.Contain(nameof(ClaimDoc))); + } + + Refused(() => CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(ClaimDoc), registry)); + var held = Raw((ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write("c1"); w.WriteArrayHeader(0); }); + Refused(() => CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(ClaimDoc), held, registry)); + Refused(() => CultCacheMessagePack.CreateCodec(registry).Serialize(doc.Items, typeof(List), typeof(ClaimDoc))); + + var scratch = Directory.CreateTempSubdirectory().FullName; + try + { + var path = Path.Combine(scratch, "claim.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }); + Assert.That(Assert.Catch(() => cache.Commit(batch => batch.Upsert(typeof(ClaimDoc), doc, new CultRecordKey("c1"))))!.ToString(), + Does.Contain(nameof(ClaimArmResolver))); + Assert.That(File.Exists(path), Is.False, "nothing reached disk"); + } + finally + { + Directory.Delete(scratch, true); + } + } + + [Union(0, typeof(ClaimArm))] + public abstract class ClaimNode { } + + [MessagePackObject] + public sealed class ClaimArm : ClaimNode + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + } + + [CultDocument("tests.claim_doc", "tests.claim_doc.v1")] + [MessagePackObject] + public sealed class ClaimDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + public sealed class ClaimArmResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new ClaimArmResolver(); + public IMessagePackFormatter? GetFormatter() => typeof(T) == typeof(ClaimArm) ? (IMessagePackFormatter)(object)new ClaimFormatter() : null; + + private sealed class ClaimFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, ClaimArm value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(1); + writer.Write(value.N); + } + + public ClaimArm Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + reader.ReadArrayHeader(); + return new ClaimArm { N = reader.ReadInt32() }; + } + } + } + // The guard wraps the composite, so the composite's own order is still the contract: a consumer resolver outranks the // document resolver (CultRecordRef) and MessagePack's own object formatter, here for a type it also knows. [Test] @@ -568,7 +642,7 @@ namespace GameCult.Caching.Tests } // An arm is [key, armSlots]; the keys here start at 3, skip 0-2, 4, 6 and 7, and never reuse a retired key. - [Union(3, typeof(Leaf)), Union(5, typeof(Branch)), Union(8, typeof(Unit8)), Union(9, typeof(StubArm))] + [Union(3, typeof(Leaf)), Union(5, typeof(Branch)), Union(8, typeof(Unit8)), Union(9, typeof(Nine))] public abstract class GuardNode { } [MessagePackObject] @@ -592,11 +666,25 @@ namespace GameCult.Caching.Tests [MessagePackObject] public sealed class Unit8 : GuardNode { [Key(0)] [CultElementId] public string Id = string.Empty; } + [MessagePackObject] public sealed class Nine : GuardNode { [Key(0)] [CultElementId] public string Id = string.Empty; } + // A subtype the union does not name. [MessagePackObject] public sealed class Rogue : GuardNode { [Key(0)] public string Note = string.Empty; } - // Written and read by a consumer resolver, not by MessagePack. - public sealed class StubArm : GuardNode { [Key(0)] [CultElementId] public string Id = string.Empty; } + // Written and read by a consumer resolver, not by MessagePack. It can only sit outside a list: a list element is written + // by its [Key] layout, or its id never reaches disk (ListElementsClaimedByAResolverAreRefused). + [Union(9, typeof(StubArm))] + public abstract class StubNode { } + + public sealed class StubArm : StubNode { [Key(0)] [CultElementId] public string Id = string.Empty; } + + [CultDocument("tests.stub_doc", "tests.stub_doc.v1")] + [MessagePackObject] + public sealed class StubDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public StubNode? Root; + } public sealed class StubArmResolver : IFormatterResolver {