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:
parent
c9a7d9c2a4
commit
8e0cd3e32d
5 changed files with 89 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue