Compare dictionary keys under their owning document's serializer

Key identity serialized a key with the options of the key type's own
assembly, but the store writes it inside the document with the document
assembly's options, [CultCacheFormatterResolver] resolvers included. A key
that failed to serialize fell back to comparison by ToString.

The inspector codec now takes the owning document type, ReplaceKey and
FreshKey take it, and a key serializes as its declared key type under that
document's options, the store's own path. A key the document cannot
serialize is refused with a notice; the text fallback is deleted.

The comment and README said "the dictionary's own comparer"; it is the
rebuilt dictionary type's default comparer, and now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 14:38:24 +02:00
commit 8e0cd3e32d
5 changed files with 89 additions and 54 deletions

View file

@ -19,11 +19,12 @@ namespace GameCult.Caching.MessagePack
public static class CultCacheMessagePack
{
// The inspection model over this codec. Values serialize without the document guard, so dictionary keys and other
// non-document values compare by their bytes; clones deserialize as registered documents.
// The inspection model over this codec. Values serialize without the document guard but with their owning document's
// options, resolvers included, so dictionary keys compare by the bytes the store writes; clones deserialize as
// registered documents.
public static CultInspectorModel CreateInspectorModel(CultDocumentRegistry registry) => new CultInspectorModel(
registry,
(value, type) => global::MessagePack.MessagePackSerializer.Serialize(type, value, CultDocumentMessagePackSerialization.OptionsFor(type.Assembly)),
(value, type, document) => global::MessagePack.MessagePackSerializer.Serialize(type, value, CultDocumentMessagePackSerialization.OptionsFor(document.Assembly)),
(type, bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, registry));
// Opening hydrates. A consumer that wants a fresh store removes the records it does not keep, upserts, and

View file

@ -281,14 +281,16 @@ namespace GameCult.Caching
typeof(int), typeof(long), typeof(uint), typeof(short), typeof(ushort), typeof(byte), typeof(sbyte)
};
private readonly Func<object, Type, byte[]> _serialize;
private readonly Func<object, Type, Type, byte[]> _serialize;
private readonly Func<Type, byte[], object> _deserialize;
private readonly Dictionary<Type, CultInspectorShape> _shapes = new Dictionary<Type, CultInspectorShape>();
private readonly Dictionary<MemberInfo, CultInspectorMetadata> _metadata = new Dictionary<MemberInfo, CultInspectorMetadata>();
// serialize and deserialize are the store's codec (CultCacheMessagePack.CreateInspectorModel for .cc stores). Edits
// clone documents through both; dictionary keys compare by serialize, so it must take any value, not only documents.
public CultInspectorModel(CultDocumentRegistry registry, Func<object, Type, byte[]> serialize, Func<Type, byte[], object> deserialize)
// serialize (value, value type, owning document type) and deserialize are the store's codec
// (CultCacheMessagePack.CreateInspectorModel for .cc stores). Edits clone documents through both; dictionary keys
// compare by serialize under their owning document, exactly as the store writes them inside it, so serialize must
// take any value, not only documents.
public CultInspectorModel(CultDocumentRegistry registry, Func<object, Type, Type, byte[]> serialize, Func<Type, byte[], object> deserialize)
{
Registry = registry ?? throw new ArgumentNullException(nameof(registry));
_serialize = serialize ?? throw new ArgumentNullException(nameof(serialize));
@ -335,7 +337,7 @@ namespace GameCult.Caching
}
}
public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type));
public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type, type));
public CultInspectorEdit BeginEdit(CultStoredDocument record)
{
@ -410,65 +412,76 @@ namespace GameCult.Caching
return dictionary;
}
// The key entry `index` of a dictionary holding `keys` takes when a lowering offers `candidate`: the candidate, or
// the kept key and a notice saying why. An unchanged key is never refused. Any other key must be non-null, not an
// empty record reference, and not taken by another entry, so BuildDictionary cannot throw.
public object? ReplaceKey(Type dictionaryType, IReadOnlyList<object?> keys, int index, object? candidate, out string? notice)
// The key entry `index` of a dictionary in a documentType document holding `keys` takes when a lowering offers
// `candidate`: the candidate, or the kept key and a notice saying why. An unchanged key is never refused. Any other
// key must be non-null, not an empty record reference, and not taken by another entry, so BuildDictionary cannot
// throw. A key the document cannot serialize is refused: the store could not write it.
public object? ReplaceKey(Type documentType, Type dictionaryType, IReadOnlyList<object?> keys, int index, object? candidate, out string? notice)
{
var shape = DictionaryShape(dictionaryType);
var identity = KeyIdentity(candidate);
var refusal = identity == KeyIdentity(keys[index]) ? null
: identity == null ? "a null key"
: identity == RecordRefIdentity ? "an empty record reference as a key"
: Taken(shape, candidate!, keys.Where((_, i) => i != index)) ? "a duplicate key"
: null;
string? refusal;
try
{
var identity = KeyIdentity(documentType, shape, candidate);
refusal = identity == KeyIdentity(documentType, shape, keys[index]) ? null
: identity == null ? "a null key"
: identity == RecordRefIdentity ? "an empty record reference as a key"
: Taken(documentType, shape, candidate!, keys.Where((_, i) => i != index)) ? "a duplicate key"
: null;
}
catch (Exception exception)
{
refusal = $"a key {documentType.Name} cannot serialize ({exception.GetBaseException().Message})";
}
notice = refusal == null ? null : $"Refused {refusal} on entry {index}; its key was kept.";
return refusal == null ? candidate : keys[index];
}
// A key to add beside `keys`: a record-reference key takes the first candidate record not already used; any other
// key is the type's default. Null with a notice when no such key is free.
public object? FreshKey(Type dictionaryType, IReadOnlyList<object?> keys, IEnumerable<CultStoredDocument> records, out string? notice)
// A key to add beside `keys` in a documentType document: a record-reference key takes the first candidate record not
// already used; any other key is the type's default. Null with a notice when no such key is free or serializable.
public object? FreshKey(Type documentType, Type dictionaryType, IReadOnlyList<object?> keys, IEnumerable<CultStoredDocument> records, out string? notice)
{
var shape = DictionaryShape(dictionaryType);
var keyType = shape.KeyType!;
var candidates = ShapeOf(keyType).Kind == CultInspectorValueKind.RecordRef
? RecordCandidates(keyType, records).Select(record => CreateRecordRef(keyType, record.Key.Value))
: new[] { CreateDefault(keyType) };
var fresh = candidates.FirstOrDefault(key => key != null && !Taken(shape, key, keys));
notice = fresh == null ? $"No unused {keyType.Name} key is available; nothing was added." : null;
return fresh;
try
{
var fresh = candidates.FirstOrDefault(key => key != null && !Taken(documentType, shape, key, keys));
notice = fresh == null ? $"No unused {keyType.Name} key is available; nothing was added." : null;
return fresh;
}
catch (Exception exception)
{
notice = $"{documentType.Name} cannot serialize a {keyType.Name} key ({exception.GetBaseException().Message}); nothing was added.";
return null;
}
}
// A key is taken when another serializes the same (a CultRecordRef<T> by its key string, null and "" alike) or when
// the dictionary's own comparer calls them equal (0.0 and -0.0 serialize apart but are one double key).
private bool Taken(CultInspectorShape dictionary, object key, IEnumerable<object?> keys)
// the rebuilt dictionary type's default comparer calls them equal (0.0 and -0.0 serialize apart but are one double key).
private bool Taken(Type documentType, CultInspectorShape dictionary, object key, IEnumerable<object?> keys)
{
var identity = KeyIdentity(key);
var identity = KeyIdentity(documentType, dictionary, key);
var probe = (IDictionary)Activator.CreateInstance(dictionary.BuildType!)!;
var filler = dictionary.ValueType!.IsValueType ? Activator.CreateInstance(dictionary.ValueType) : null;
foreach (var other in keys.Where(other => other != null))
{
if (KeyIdentity(other) == identity) return true;
if (KeyIdentity(documentType, dictionary, other) == identity) return true;
probe[other!] = filler;
}
return probe.Contains(key);
}
private string? KeyIdentity(object? key)
{
if (key == null) return null;
if (key is ICultRecordRef reference) return RecordRefIdentity + reference.Key.Value;
try
{
return "value:" + Convert.ToBase64String(_serialize(key, key.GetType()));
}
catch (Exception)
{
return key.GetType().FullName + ":" + key;
}
}
// A key's bytes as its declared key type under the owning document's serializer, the store's own path. Throws when
// that serializer cannot write the key.
private string? KeyIdentity(Type documentType, CultInspectorShape dictionary, object? key) =>
key == null ? null
: key is ICultRecordRef reference ? RecordRefIdentity + reference.Key.Value
: "value:" + Convert.ToBase64String(_serialize(key, dictionary.KeyType!, documentType));
private CultInspectorShape DictionaryShape(Type dictionaryType)
{

View file

@ -33,6 +33,7 @@ namespace GameCult.Unity.Caching.Editor
private readonly Dictionary<string, bool> _foldouts = new Dictionary<string, bool>(StringComparer.Ordinal);
private readonly Dictionary<string, string> _notices = new Dictionary<string, string>(StringComparer.Ordinal);
private string _path = string.Empty;
private Type _document;
private bool _drawerFailed;
internal CultInspector(CultInspectorModel model)
@ -61,6 +62,7 @@ namespace GameCult.Unity.Caching.Editor
internal bool DrawDocument(CultInspectorEdit edit)
{
_drawerFailed = false;
_document = edit.Document.GetType();
DrawMembers(edit.Document, edit.Source.Descriptor.DocumentType, edit.Source.Key.Value);
return !_drawerFailed;
}
@ -240,7 +242,7 @@ namespace GameCult.Unity.Caching.Editor
var item = DrawValue("Value", shape.ValueType, entries[i].Value, member, path + "{" + i + "}.value");
if (EditorGUI.EndChangeCheck())
{
key = Model.ReplaceKey(shape.Type, entries.Select(e => e.Key).ToArray(), i, key, out var notice);
key = Model.ReplaceKey(_document, shape.Type, entries.Select(e => e.Key).ToArray(), i, key, out var notice);
Notice(path, notice);
entries[i] = new KeyValuePair<object, object>(key, item);
changed = true;
@ -259,7 +261,7 @@ namespace GameCult.Unity.Caching.Editor
if (Add(shape.ValueType, path, out var created))
{
var fresh = Model.FreshKey(shape.Type, entries.Select(e => e.Key).ToArray(), Records, out var notice);
var fresh = Model.FreshKey(_document, shape.Type, entries.Select(e => e.Key).ToArray(), Records, out var notice);
Notice(path, notice);
if (fresh != null)
{

View file

@ -48,8 +48,9 @@ its declared subtypes, nested keyed object, struct edited in place through its
public fields and settable properties, or unsupported with a reason), drawer claim resolution
(`CultInspectorDrawerClaims`), and the edit decisions: the key a dictionary
entry keeps and the notice when a key is refused (null, an empty record
reference, or a duplicate: serialized the same as another key or equal by the
dictionary's own comparer), the fresh key an added entry gets, what a new list
reference, a duplicate: serialized the same as another key under the owning
document's serializer or equal by the rebuilt dictionary type's default
comparer, or a key that document cannot serialize), the fresh key an added entry gets, what a new list
element, dictionary value or union pick may be, and how an integer edit clamps
to its type. Edits work on a copy (`CultInspectorEdit`): a refused upsert
leaves the cached object unchanged. The Studio is one lowering of that model

View file

@ -122,7 +122,7 @@ namespace GameCult.Caching.Tests
var keys = new object?[] { Ref("a"), Ref("b") };
string? Refusal(object? candidate)
{
var kept = model.ReplaceKey(links, keys, 0, candidate, out var notice);
var kept = model.ReplaceKey(typeof(InspectItem),links, keys, 0, candidate, out var notice);
Assert.That(kept, Is.EqualTo(notice == null ? candidate : keys[0]), "a refused key keeps the entry's key");
return notice;
}
@ -134,16 +134,16 @@ namespace GameCult.Caching.Tests
Assert.That(Refusal(Ref("c")), Is.Null);
// An entry an old store left with an empty reference key keeps it through an edit of its value.
Assert.That(model.ReplaceKey(links, new object?[] { Ref(""), Ref("b") }, 0, default(CultRecordRef<InspectItem>), out var unchanged),
Assert.That(model.ReplaceKey(typeof(InspectItem),links, new object?[] { Ref(""), Ref("b") }, 0, default(CultRecordRef<InspectItem>), out var unchanged),
Is.EqualTo(default(CultRecordRef<InspectItem>)));
Assert.That(unchanged, Is.Null, "an unchanged key is never refused");
using var cache = new CultCache(Registry);
var first = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "first" });
var second = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "second" });
var fresh = model.FreshKey(links, new object?[] { new CultRecordRef<InspectItem>(first) }, cache.AllStoredDocuments, out _);
var fresh = model.FreshKey(typeof(InspectItem),links, new object?[] { new CultRecordRef<InspectItem>(first) }, cache.AllStoredDocuments, out _);
Assert.That(CultInspectorModel.RecordKey(fresh), Is.EqualTo(second.Value));
Assert.That(model.FreshKey(links, new object?[] { new CultRecordRef<InspectItem>(first), new CultRecordRef<InspectItem>(second) },
Assert.That(model.FreshKey(typeof(InspectItem),links, new object?[] { new CultRecordRef<InspectItem>(first), new CultRecordRef<InspectItem>(second) },
cache.AllStoredDocuments, out var none), Is.Null);
Assert.That(none, Does.Contain("No unused"));
}
@ -155,12 +155,12 @@ namespace GameCult.Caching.Tests
var doubles = typeof(Dictionary<double, int>);
var keys = new object?[] { 0.0, 1.0 };
Assert.That(model.ReplaceKey(doubles, keys, 1, -0.0, out var notice), Is.EqualTo(1.0));
Assert.That(model.ReplaceKey(typeof(InspectItem),doubles, keys, 1, -0.0, out var notice), Is.EqualTo(1.0));
Assert.That(notice, Does.Contain("duplicate"), "-0.0 serializes apart from 0.0 but Equals it");
Assert.That(() => model.BuildDictionary(doubles, keys.Select(key => new KeyValuePair<object?, object?>(key, 0))), Throws.Nothing);
Assert.That(model.FreshKey(doubles, new object?[] { -0.0 }, Array.Empty<CultStoredDocument>(), out _), Is.Null,
Assert.That(model.FreshKey(typeof(InspectItem),doubles, new object?[] { -0.0 }, Array.Empty<CultStoredDocument>(), out _), Is.Null,
"the default 0.0 is taken by -0.0");
Assert.That(model.ReplaceKey(doubles, new object?[] { 0.0 }, 0, -0.0, out _), Is.EqualTo(-0.0), "a key may replace itself");
Assert.That(model.ReplaceKey(typeof(InspectItem),doubles, new object?[] { 0.0 }, 0, -0.0, out _), Is.EqualTo(-0.0), "a key may replace itself");
}
[Test]
@ -169,13 +169,31 @@ namespace GameCult.Caching.Tests
var model = Model();
var dictionary = typeof(Dictionary<InspectKey, int>);
Assert.That(model.FreshKey(dictionary, new object?[] { new InspectKey(1) }, Array.Empty<CultStoredDocument>(), out _),
Assert.That(model.FreshKey(typeof(InspectItem),dictionary, new object?[] { new InspectKey(1) }, Array.Empty<CultStoredDocument>(), out _),
Is.EqualTo(new InspectKey(0)), "keys whose text collides are still distinct keys");
Assert.That(model.ReplaceKey(dictionary, new object?[] { new InspectKey(1), new InspectKey(2) }, 1, new InspectKey(3), out var notice),
Assert.That(model.ReplaceKey(typeof(InspectItem),dictionary, new object?[] { new InspectKey(1), new InspectKey(2) }, 1, new InspectKey(3), out var notice),
Is.EqualTo(new InspectKey(3)));
Assert.That(notice, Is.Null);
}
// Pair's formatter is declared only by this test assembly (AssemblyInfo). A key's bytes come from its owning document's
// options, so a document here stores it and a document from an assembly declaring no resolver cannot.
[Test]
public void DictionaryKeysSerializeUnderTheOwningDocument()
{
var model = Model();
var pairs = typeof(Dictionary<Pair, int>);
var keys = new object?[] { new Pair { A = 1 }, new Pair { A = 2 } };
Assert.That(model.ReplaceKey(typeof(InspectItem), pairs, keys, 1, new Pair { A = 3 }, out var accepted), Is.EqualTo(new Pair { A = 3 }));
Assert.That(accepted, Is.Null);
Assert.That(model.ReplaceKey(typeof(object), pairs, keys, 1, new Pair { A = 3 }, out var refused), Is.EqualTo(keys[1]),
"a key its document cannot serialize is refused, not compared by text");
Assert.That(refused, Does.Contain("cannot serialize"));
Assert.That(model.FreshKey(typeof(object), pairs, new object?[] { new Pair { A = 1 } }, Array.Empty<CultStoredDocument>(), out var none), Is.Null);
Assert.That(none, Does.Contain("cannot serialize"));
}
[Test]
public async Task RefusedCommitLeavesTheCachedDocumentUnchanged()
{