CultCache: list elements are written by their [Key] layout; registration and the codec refuse otherwise

Registration: a concrete element type of an object list that is not keyed, and
[MessagePackFormatter] on any type or member on the id path or on the id member, are
refused. The id path is a fixed point over the walk's edges. The codec refuses, once per
document type and on every encode and decode path, a resolver ahead of StandardResolver
that formats a type on that path. UnionArmGuardTests moves StubArm out of the list union.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 21:52:36 +02:00
commit b091d21f81
7 changed files with 299 additions and 30 deletions

View file

@ -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.

View file

@ -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<IFormatterResolver>());
private static readonly (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Plain = Compose(Array.Empty<IFormatterResolver>());
private static readonly ConcurrentDictionary<Assembly, MessagePackSerializerOptions> 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<Assembly, (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead)> 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<Type, string?> 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<CultCacheFormatterResolverAttribute>()
.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}.");
}

View file

@ -294,6 +294,10 @@ exclusive open of the same lock file exclude each other is not established.
for `X` that `Doc<X>` 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<T>` is a
permitted dictionary key, compared by its key string.

View file

@ -95,34 +95,52 @@ namespace GameCult.Caching
internal sealed class Survey
{
public List<string> 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<Type> 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<Type> 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<Type>(), survey);
var graph = new Graph();
VisitObject(documentType, documentType, new HashSet<Type>(), survey, graph);
Settle(documentType, survey, graph);
return survey;
}
private static void VisitObject(Type documentType, Type type, HashSet<Type> seen, Survey survey)
private static void VisitObject(Type documentType, Type type, HashSet<Type> 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<UnionAttribute>(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<Type> seen, Survey survey)
private static void VisitMemberType(Type documentType, Type owner, MemberInfo member, Type memberType, HashSet<Type> 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<Type>(graph.Lists.Select(list => list.Owner));
var members = new List<MemberInfo>(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<Type>();
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)

View file

@ -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

View file

@ -290,12 +290,71 @@ namespace GameCult.Caching.Tests
Assert.That(Assert.Throws<CultElementIdException>(() => 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<EIded> Sub { get; set; } = new(); }
[MessagePackObject] public class EBareNest { [Key(0)] [CultElementId] public string Id { get; set; } = ""; [Key(1)] public List<EBare> 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<ArmBase> 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<PlainValue> 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<FormattedElement> Items { get; set; } = new(); }
[MessagePackObject] public class HFormattedList { [Key(0)] [MessagePackFormatter(typeof(NoFormatter))] public List<EIded> 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<FormattedIdElement> Items { get; set; } = new(); }
[MessagePackObject] [MessagePackFormatter(typeof(NoFormatter))] public class FormattedOwner { [Key(0)] public List<EIded> 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<string> 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<EIded> 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<EBare> Items { get; set; } = new(); }
[MessagePackObject] public class HArray { [Key(0)] public EBare[] Items { get; set; } = Array.Empty<EBare>(); }
[MessagePackObject] public class HDictionary { [Key(0)] public Dictionary<string, HList> Map { get; set; } = new(); }

View file

@ -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<StubArm>());
}
// 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<InvalidOperationException>(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<ClaimNode>), 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<ClaimNode> Items = new();
}
public sealed class ClaimArmResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new ClaimArmResolver();
public IMessagePackFormatter<T>? GetFormatter<T>() => typeof(T) == typeof(ClaimArm) ? (IMessagePackFormatter<T>)(object)new ClaimFormatter() : null;
private sealed class ClaimFormatter : IMessagePackFormatter<ClaimArm>
{
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
{