diff --git a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs index 1592bf36..e1a68c12 100644 --- a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs +++ b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs @@ -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 diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs index 84044be0..7075c10a 100644 --- a/src/GameCult.Caching/CultInspectorModel.cs +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -281,14 +281,16 @@ namespace GameCult.Caching typeof(int), typeof(long), typeof(uint), typeof(short), typeof(ushort), typeof(byte), typeof(sbyte) }; - private readonly Func _serialize; + private readonly Func _serialize; private readonly Func _deserialize; private readonly Dictionary _shapes = new Dictionary(); private readonly Dictionary _metadata = new Dictionary(); - // 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 serialize, Func 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 serialize, Func 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 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 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 keys, IEnumerable 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 keys, IEnumerable 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 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 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 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) { diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs index 50f5be9d..12eed25e 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs @@ -33,6 +33,7 @@ namespace GameCult.Unity.Caching.Editor private readonly Dictionary _foldouts = new Dictionary(StringComparer.Ordinal); private readonly Dictionary _notices = new Dictionary(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(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) { diff --git a/src/GameCult.Unity/Assets/Caching/README.md b/src/GameCult.Unity/Assets/Caching/README.md index 62cb65ad..2d501552 100644 --- a/src/GameCult.Unity/Assets/Caching/README.md +++ b/src/GameCult.Unity/Assets/Caching/README.md @@ -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 diff --git a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs index 42583a1a..b4f22601 100644 --- a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs +++ b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs @@ -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), out var unchanged), + Assert.That(model.ReplaceKey(typeof(InspectItem),links, new object?[] { Ref(""), Ref("b") }, 0, default(CultRecordRef), out var unchanged), Is.EqualTo(default(CultRecordRef))); 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(first) }, cache.AllStoredDocuments, out _); + var fresh = model.FreshKey(typeof(InspectItem),links, new object?[] { new CultRecordRef(first) }, cache.AllStoredDocuments, out _); Assert.That(CultInspectorModel.RecordKey(fresh), Is.EqualTo(second.Value)); - Assert.That(model.FreshKey(links, new object?[] { new CultRecordRef(first), new CultRecordRef(second) }, + Assert.That(model.FreshKey(typeof(InspectItem),links, new object?[] { new CultRecordRef(first), new CultRecordRef(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); 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(key, 0))), Throws.Nothing); - Assert.That(model.FreshKey(doubles, new object?[] { -0.0 }, Array.Empty(), out _), Is.Null, + Assert.That(model.FreshKey(typeof(InspectItem),doubles, new object?[] { -0.0 }, Array.Empty(), 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); - Assert.That(model.FreshKey(dictionary, new object?[] { new InspectKey(1) }, Array.Empty(), out _), + Assert.That(model.FreshKey(typeof(InspectItem),dictionary, new object?[] { new InspectKey(1) }, Array.Empty(), 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); + 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(), out var none), Is.Null); + Assert.That(none, Does.Contain("cannot serialize")); + } + [Test] public async Task RefusedCommitLeavesTheCachedDocumentUnchanged() {