diff --git a/src/GameCult.Caching/CultElementIds.cs b/src/GameCult.Caching/CultElementIds.cs index 99eb094a..89ff651b 100644 --- a/src/GameCult.Caching/CultElementIds.cs +++ b/src/GameCult.Caching/CultElementIds.cs @@ -617,12 +617,12 @@ namespace GameCult.Caching // A type MessagePack writes as one value the walk cannot look into: its own formatter ([MessagePackFormatter] on the type, // inherited), [DataContract] on a type that is not [MessagePackObject] (the object resolver writes its members), or string - // keys (KeyAsPropertyName, or any [Key] with a string key). Such a type is Leaf, even when it is also enumerable. + // keys (KeyAsPropertyName, or a [Key] with a string key on a member MessagePack writes: a private or [IgnoreMember] one is not written). Such a type is Leaf, even when it is also enumerable. private static bool IsWrittenWhole(Type type) => type.IsDefined(typeof(MessagePackFormatterAttribute), true) || (type.IsDefined(typeof(DataContractAttribute), true) && !type.IsDefined(typeof(MessagePackObjectAttribute), true)) || type.GetCustomAttribute(true)?.KeyAsPropertyName == true || - AllInstanceMembers(type).Any(member => member.GetCustomAttribute(true)?.StringKey != null); + MembersOf(type).Any(member => member.GetCustomAttribute(true)?.StringKey != null); // A type MessagePack serializes by key: [MessagePackObject] or [Union]. private static bool IsKeyed(Type type) => diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 6e47742f..fc5cd308 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -195,6 +195,15 @@ A document type that holds a list of objects gives every object type in that lis `[Key]`. The id is what lets a variant override one list element instead of the whole list. Lists of strings, numbers and other non-object values need none. +The rule covers the shapes the id walk owns: arrays, `IEnumerable` classes and +interfaces, dictionary values, and integer-keyed `[MessagePackObject]` and `[Union]` +types. Any other holder is one whole value: `ImmutableArray`, `ArraySegment`, +`Memory`, `KeyValuePair`, tuples, `Lazy`, a class without `[MessagePackObject]`, +a `[DataContract]` or string-keyed type, and a type or member declaring +`[MessagePackFormatter]`. Its elements carry no element ids: a `[CultElementId]` +member inside it is a plain string, never minted or checked, and a variant can only +replace the holder whole. + ```csharp [MessagePackObject] public sealed class Line diff --git a/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs b/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs index 6fc04aad..3e0283fa 100644 --- a/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs @@ -357,6 +357,8 @@ namespace GameCult.Caching.Tests { Assert.That(Problems(typeof(HOverFormattedId)).Single(), Does.Contain("Id").And.Contain("[MessagePackFormatter]")); Assert.That(Problems(typeof(OwnerOverFormatted)), Is.Empty, "the override inherits its base's formatter, so the member is a whole value"); + Assert.That(CultElementIds.Inspect(typeof(OwnerOverFormatted)).IdPath, Is.Empty, "a walked override would put its list on the id path"); + Assert.That(Assign(new OwnerOverFormatted { Items = { new EIded() } }, "r", deterministic: false), Is.EqualTo(0), "and mint into a list its formatter may drop ids from"); Assert.That(Problems(typeof(HIdOverride)), Is.Empty, "a base [CultElementId] is the override's id"); var root = new HIdOverride { Items = { new IdOverride() } }; @@ -544,7 +546,7 @@ namespace GameCult.Caching.Tests Assert.That(Problems(typeof(ApIgn)), Is.Empty, "an ignored private member is not written"); Assert.That(Problems(typeof(PgHold)), Is.Empty, "a list behind a private getter is not written without AllowPrivate"); - foreach (var type in new[] { typeof(EIded), typeof(HHiddenLeaf), typeof(PubIgn), typeof(ApIgn), typeof(PgHold), typeof(MemberMix), typeof(HOptHidden), typeof(ApDerived), typeof(NoApDerived), typeof(ApInheritor) }) + foreach (var type in new[] { typeof(EIded), typeof(HHiddenLeaf), typeof(PubIgn), typeof(ApIgn), typeof(PgHold), typeof(MemberMix), typeof(HOptHidden), typeof(ApDerived), typeof(NoApDerived), typeof(ApInheritor), typeof(ApIgnAp), typeof(PSkH), typeof(ISkH) }) { var allowPrivate = type.GetCustomAttribute(true)!.AllowPrivate; Assert.That(CultElementIds.WrittenKeyedMembers(type).Select(member => member.Name), @@ -661,6 +663,33 @@ namespace GameCult.Caching.Tests RefusesBare(typeof(Whole>)); } + // A string key counts only on a member MessagePack writes: a private or [IgnoreMember] one leaves the type integer-keyed and owned. + [Test] + public void AStringKeyOnAMemberMessagePackDoesNotWriteLeavesTheTypeOwned() + { + foreach (var holder in new object[] { new PSkH { Items = { new EIded(), new EIded() } }, new ISkH { Items = { new EIded(), new EIded() } } }) + { + Assert.That(Problems(holder.GetType()), Is.Empty, holder.GetType().Name); + Assert.That(CultElementIds.Inspect(holder.GetType()).IdPath, Does.Contain(holder.GetType()), holder.GetType().Name); + Assert.That(Assign(holder, "r", deterministic: false), Is.EqualTo(2), holder.GetType().Name); + Assert.That(CultElementIds.Holds(holder.GetType(), holder), Is.True, holder.GetType().Name); + } + + Assert.That(Problems(typeof(Whole>)).Single(), Does.Contain("[CultElementId]")); + Assert.That(Problems(typeof(Whole>)).Single(), Does.Contain("[CultElementId]"), "an element whose only string key is ignored is as bare as its twin"); + } + + // The AllowPrivate edges: an [IgnoreMember] member is not written even under it, and only the element's own most derived + // declaration lets a private setter be read back. + [Test] + public void TheAllowPrivateEdgesFollowWhatMessagePackWrites() + { + Assert.That(CultElementIds.WrittenKeyedMembers(typeof(ApIgnAp)), Is.Empty, "an ignored member is not written under AllowPrivate"); + Assert.That(Problems(typeof(HApIgnId)).Single(), Does.Contain("does not write").And.Contain("Id")); + Assert.That(Problems(typeof(HNoApPrivSet)).Single(), Does.Contain("setter is not public").And.Contain("Id"), + "the derived plain [MessagePackObject] decides: its private setter is not read back, whatever its base declares"); + } + // MessagePack writes an AllowPrivate type with every member up its base chain, and the most derived [MessagePackObject] // declaration decides whether that is so. [Test] @@ -993,6 +1022,16 @@ namespace GameCult.Caching.Tests public class ApInheritor : ApDerived { } [MessagePackObject] public class SkElem { [Key("x")] public string X { get; set; } = ""; } [System.Runtime.Serialization.DataContract] public class DcHold { [System.Runtime.Serialization.DataMember(Order = 0)] public List Items = new(); } + [MessagePackObject(AllowPrivate = true)] public class ApIgnAp { [Key(0)] [IgnoreMember] private List l = new(); } + [MessagePackObject] public class PSkH { [Key(0)] public List Items = new(); [Key("x")] private int x = 1; } + [MessagePackObject] public class ISkH { [Key(0)] public List Items = new(); [IgnoreMember] [Key("x")] public int X { get; set; } } + [MessagePackObject] public class NoIdEl { [Key(0)] public int N { get; set; } [IgnoreMember] [Key("x")] public int X { get; set; } } + [MessagePackObject] public class NoIdPlain { [Key(0)] public int N { get; set; } } + [MessagePackObject(AllowPrivate = true)] public class EApIgnId { [Key(0)] public int N { get; set; } [Key(1)] [IgnoreMember] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HApIgnId { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject(AllowPrivate = true)] public class ApIdBase { [Key(0)] public int N { get; set; } } + [MessagePackObject] public class ENoApPrivSet : ApIdBase { [Key(1)] [CultElementId] public string? Id { get; private set; } } + [MessagePackObject] public class HNoApPrivSet { [Key(0)] public List Items { get; set; } = new(); } [MessagePackObject(AllowPrivate = true)] public class EApPrivSet { [Key(0)] [CultElementId] public string? Id { get; private set; } } [MessagePackObject] public class HApPrivSet { [Key(0)] public List Items { get; set; } = new(); }