Persist get-only and readonly members the way MessagePack does
The reflective registry now decides persistence from the same two facts
MessagePack 3.1.7's DynamicObjectResolver uses: whether it can assign a keyed
member after construction, and which constructor it picks.
- Every keyed public member is discovered, get-only properties included.
MessagePack writes them all; the registry used to leave a get-only property
out of the catalog and schema id even when a constructor filled it.
- MessagePack picks the [SerializationConstructor] if marked, otherwise the
longest visible constructor whose parameters take the keyed members by
position (probed: a longer non-matching constructor falls back to a shorter
one). A member it cannot assign is read back only when its slot is below
that constructor's parameter count.
- A readonly field is assignable under AllowPrivate (probed: round-trips with
only a parameterless constructor), and otherwise only through the picked
constructor. Keyed readonly fields that constructor fills are accepted.
- A keyed member MessagePack writes but never reads back is refused with a
message naming the constructor position: a get-only property, a readonly
field without AllowPrivate, or a non-public setter without AllowPrivate,
when the picked constructor does not fill it (probed: loaded as default).
- An unkeyed public get-only property is refused; MessagePack throws "all
public members must mark KeyAttribute or IgnoreMemberAttribute". The unkeyed
message now names both fixes.
Schema ids change for five GameCult.Eve.Surface documents, whose keyed members
are all get-only properties filled by a [SerializationConstructor]:
gamecult.eve.surface, gamecult.eve.asset_catalog,
gamecult.eve.command_invocation, gamecult.eve.command_receipt and
gamecult.eve.provider_advertisement (EveProviderAdvertisementDocument). Their
catalogs gain those members. No .cc store under F:\Projects holds records of
these schemas: the gamecult.eve.provider_advertisement catalog in Gjallar's
scratch smoke stores belongs to GjallarProviderAdvertisementRecord, which has
no get-only members, and every other hit is prose or a git tree listing.
Consumer registration scan (CultLib src/tests/tools/samples, Ymir.Core,
Gjallar, AquaSynth Core/Faust, Mimir): 148 types register; the two rejections
(CounterState string key, BenchItem without [MessagePackObject]) are rejected
on 9139bf3 too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9139bf3195
commit
5bd4e4ed6e
3 changed files with 195 additions and 45 deletions
|
|
@ -34,23 +34,32 @@ A member's slot is its MessagePack `[Key(n)]` integer. MessagePack's `[Key]` and
|
|||
the registry refuses a document type without `[MessagePackObject]` and a
|
||||
non-public document type without `[MessagePackObject(AllowPrivate = true)]`.
|
||||
A member's type name is the CLR full name; a nested type is written `Outer+Inner`.
|
||||
The registry also refuses a persisted member without `[Key]`, a string
|
||||
`[Key]`, two members sharing a slot (including `new`-hidden members), a hidden
|
||||
persisted member, an override whose `[Key]` or `[IgnoreMember]` differs from its
|
||||
base declaration's (MessagePack reads the base declaration), and a readonly
|
||||
persisted field.
|
||||
The registry also refuses a public member without `[Key]` or `[IgnoreMember]`
|
||||
(get-only properties included; MessagePack throws on them), a string `[Key]`,
|
||||
two members sharing a slot (including `new`-hidden members), a hidden persisted
|
||||
member, and an override whose `[Key]` or `[IgnoreMember]` differs from its base
|
||||
declaration's (MessagePack reads the base declaration).
|
||||
|
||||
Visibility follows MessagePack's rule: the registry accepts what MessagePack
|
||||
round-trips and refuses what it would silently lose. The registry persists
|
||||
public fields and properties only. Without `AllowPrivate`, a persisted property
|
||||
needs a public setter, and a `[Key]` on a non-public member is refused because
|
||||
MessagePack skips that member. With `AllowPrivate`, private, internal and
|
||||
init-only setters are all writable. But MessagePack then also reads non-public
|
||||
members, so every non-public instance field or property must be
|
||||
`[IgnoreMember]`. A class also needs a constructor MessagePack can call. That
|
||||
is a parameterless one (public unless `AllowPrivate`), or one whose parameters
|
||||
take the members at `[Key(0)]`, `[Key(1)]`, ... in order. A primary constructor
|
||||
usually has neither.
|
||||
every keyed public field and property, get-only ones included, because
|
||||
MessagePack writes them all. A `[Key]` on a non-public member without
|
||||
`AllowPrivate` is refused because MessagePack skips that member. With
|
||||
`AllowPrivate`, MessagePack also reads non-public members, so every non-public
|
||||
instance field or property must be `[IgnoreMember]`.
|
||||
|
||||
A class needs a constructor MessagePack can call: the `[SerializationConstructor]`
|
||||
if one is marked, otherwise the longest constructor (public unless
|
||||
`AllowPrivate`) whose every parameter takes the keyed member at its position
|
||||
(`[Key(0)]`, `[Key(1)]`, ...). A parameterless constructor always qualifies; a
|
||||
primary constructor usually does not. MessagePack reads a member back through
|
||||
its setter, or through that constructor when the member's slot is below the
|
||||
constructor's parameter count. A setter counts when it is public, or any setter
|
||||
(private, internal, init-only) under `AllowPrivate`; a readonly field counts as
|
||||
writable under `AllowPrivate` only. A keyed member MessagePack cannot read back
|
||||
either way is refused: a get-only property, a readonly field without
|
||||
`AllowPrivate`, or a property whose setter is non-public without
|
||||
`AllowPrivate`, when the constructor does not fill it.
|
||||
|
||||
## Which schema a record carries
|
||||
|
||||
|
|
|
|||
|
|
@ -833,24 +833,24 @@ namespace GameCult.Caching
|
|||
public int[] DefaultedMissingSlots { get; }
|
||||
}
|
||||
|
||||
// Public instance fields and get/set properties, most-derived declaration first; GetCustomAttributes inherits a property's
|
||||
// attributes up its override chain, as MessagePack's own resolver reads them.
|
||||
// Public instance fields and properties (get-only included: MessagePack writes every keyed public member), most-derived
|
||||
// declaration first; GetCustomAttributes inherits a property's attributes up its override chain, as MessagePack's resolver reads them.
|
||||
private static IReadOnlyList<PersistedMember> DiscoverMembers(Type type)
|
||||
{
|
||||
const BindingFlags declared = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public;
|
||||
var messagePackObject = type.GetCustomAttribute<MessagePackObjectAttribute>(true);
|
||||
var allowPrivate = messagePackObject?.AllowPrivate == true;
|
||||
// Writable: MessagePack can assign the member after construction. A member that is not must be filled by the constructor.
|
||||
var candidates = new List<(MemberInfo Member, Type MemberType, int Depth, bool Writable, PropertyInfo? DivergentRoot)>();
|
||||
// Keyed get-only properties: MessagePack writes them and fills them through a constructor parameter only.
|
||||
var constructorOnly = new List<(Type MemberType, int Slot)>();
|
||||
var seenRoots = new HashSet<(Type, string)>();
|
||||
var depth = 0;
|
||||
for (var current = type; current != null && current != typeof(object); current = current.BaseType, depth++)
|
||||
{
|
||||
foreach (var field in current.GetFields(declared))
|
||||
{
|
||||
// MessagePack's rule: a readonly field is writable under AllowPrivate only.
|
||||
if (!IsIgnored(field))
|
||||
candidates.Add((field, field.FieldType, depth, !field.IsInitOnly, null));
|
||||
candidates.Add((field, field.FieldType, depth, !field.IsInitOnly || allowPrivate, null));
|
||||
}
|
||||
|
||||
foreach (var property in current.GetProperties(declared))
|
||||
|
|
@ -861,12 +861,6 @@ namespace GameCult.Caching
|
|||
var setter = chain.Select(declaration => declaration.SetMethod).FirstOrDefault(method => method != null);
|
||||
if (chain.All(declaration => declaration.GetMethod == null))
|
||||
continue;
|
||||
if (setter == null)
|
||||
{
|
||||
if (!IsIgnored(property) && GetKeyValue(property) is int constructorSlot)
|
||||
constructorOnly.Add((property.PropertyType, constructorSlot));
|
||||
continue;
|
||||
}
|
||||
// MessagePack's resolver reads the base declaration's [Key]/[IgnoreMember]; an override that changes them is refused.
|
||||
var root = chain[chain.Length - 1];
|
||||
var divergentRoot = chain.Any(declaration =>
|
||||
|
|
@ -876,7 +870,7 @@ namespace GameCult.Caching
|
|||
if (divergentRoot == null && IsIgnored(property))
|
||||
continue;
|
||||
// MessagePack's own rule: it calls a setter that is public, or any setter (init-only included) under AllowPrivate.
|
||||
candidates.Add((property, property.PropertyType, depth, setter.IsPublic || allowPrivate, divergentRoot));
|
||||
candidates.Add((property, property.PropertyType, depth, setter != null && (setter.IsPublic || allowPrivate), divergentRoot));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -901,7 +895,7 @@ namespace GameCult.Caching
|
|||
}
|
||||
}
|
||||
}
|
||||
var keyed = new List<(MemberInfo Member, Type MemberType, int Slot)>();
|
||||
var keyed = new List<(MemberInfo Member, Type MemberType, int Slot, bool Writable)>();
|
||||
foreach (var candidate in candidates.OrderBy(candidate => candidate.Depth).ThenBy(candidate => candidate.Member.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var key = GetKeyValue(candidate.Member);
|
||||
|
|
@ -912,17 +906,29 @@ namespace GameCult.Caching
|
|||
else if (key is not int slot)
|
||||
rejections.Add(UnkeyedMemberMessage(type.Name, candidate.Member.Name));
|
||||
else
|
||||
{
|
||||
if (!candidate.Writable)
|
||||
rejections.Add(candidate.Member is FieldInfo
|
||||
? NotWritableMessage(type.Name, candidate.Member.Name)
|
||||
: NonPublicSetterMessage(type.Name, candidate.Member.Name));
|
||||
keyed.Add((candidate.Member, candidate.MemberType, slot));
|
||||
}
|
||||
keyed.Add((candidate.Member, candidate.MemberType, slot, candidate.Writable));
|
||||
}
|
||||
|
||||
if (messagePackObject != null && !type.IsValueType && !HasMessagePackConstructor(type, allowPrivate, keyed.Select(entry => (entry.MemberType, entry.Slot)).Concat(constructorOnly)))
|
||||
rejections.Add(NoConstructorMessage(type.Name));
|
||||
if (messagePackObject != null)
|
||||
{
|
||||
// A member MessagePack cannot assign is read back only through the picked constructor's parameter at its slot.
|
||||
var arity = ConstructorArity(type, allowPrivate, keyed.Select(entry => (entry.MemberType, entry.Slot)));
|
||||
if (arity == null)
|
||||
rejections.Add(NoConstructorMessage(type.Name));
|
||||
else
|
||||
{
|
||||
foreach (var entry in keyed.Where(entry => !entry.Writable && entry.Slot >= arity))
|
||||
{
|
||||
rejections.Add(entry.Member switch
|
||||
{
|
||||
FieldInfo => ReadonlyUnfilledMessage(type.Name, entry.Member.Name, entry.Slot),
|
||||
PropertyInfo property when property.SetMethod == null && OverrideChain(property).All(declaration => declaration.SetMethod == null) =>
|
||||
GetOnlyUnfilledMessage(type.Name, entry.Member.Name, entry.Slot),
|
||||
_ => NonPublicSetterMessage(type.Name, entry.Member.Name)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in keyed.GroupBy(entry => entry.Slot).Where(group => group.Count() > 1).OrderBy(group => group.Key))
|
||||
{
|
||||
|
|
@ -965,18 +971,23 @@ namespace GameCult.Caching
|
|||
}
|
||||
}
|
||||
|
||||
// MessagePack's DynamicObjectResolver: the [SerializationConstructor] if one is marked, otherwise any public constructor (any
|
||||
// constructor under AllowPrivate) whose every parameter takes the keyed member at its position (slot == parameter index).
|
||||
private static bool HasMessagePackConstructor(Type type, bool allowPrivate, IEnumerable<(Type MemberType, int Slot)> members)
|
||||
// MessagePack's DynamicObjectResolver picks the [SerializationConstructor] if one is marked, otherwise the longest public constructor
|
||||
// (any constructor under AllowPrivate) whose every parameter takes the keyed member at its position (slot == parameter index).
|
||||
// Returns that constructor's parameter count, 0 for a struct with none, or null when MessagePack finds no constructor.
|
||||
private static int? ConstructorArity(Type type, bool allowPrivate, IEnumerable<(Type MemberType, int Slot)> members)
|
||||
{
|
||||
var slots = new Dictionary<int, Type>();
|
||||
foreach (var member in members)
|
||||
slots.TryAdd(member.Slot, member.MemberType);
|
||||
var constructors = type.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
var marked = constructors.Where(constructor => constructor.IsDefined(typeof(SerializationConstructorAttribute))).ToArray();
|
||||
return (marked.Length > 0 ? marked : constructors.Where(constructor => allowPrivate || constructor.IsPublic)).Any(constructor =>
|
||||
constructor.GetParameters().All(parameter =>
|
||||
slots.TryGetValue(parameter.Position, out var memberType) && parameter.ParameterType.IsAssignableFrom(memberType)));
|
||||
var arities = (marked.Length > 0 ? marked : constructors.Where(constructor => allowPrivate || constructor.IsPublic))
|
||||
.Select(constructor => constructor.GetParameters())
|
||||
.Where(parameters => parameters.All(parameter =>
|
||||
slots.TryGetValue(parameter.Position, out var memberType) && parameter.ParameterType.IsAssignableFrom(memberType)))
|
||||
.Select(parameters => (int?)parameters.Length)
|
||||
.ToArray();
|
||||
return arities.Length > 0 ? arities.Max() : type.IsValueType && marked.Length == 0 ? 0 : null;
|
||||
}
|
||||
|
||||
private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name;
|
||||
|
|
@ -991,13 +1002,16 @@ namespace GameCult.Caching
|
|||
$"Cult document {documentTypeName} member {memberName} is non-public; [MessagePackObject(AllowPrivate = true)] makes MessagePack read it, so mark it [IgnoreMember].";
|
||||
|
||||
internal static string UnkeyedMemberMessage(string documentTypeName, string memberName) =>
|
||||
$"Cult document {documentTypeName} member {memberName} has no [Key]; every persisted member of a [CultDocument] type needs an explicit [Key(n)].";
|
||||
$"Cult document {documentTypeName} member {memberName} has no [Key]; MessagePack requires [Key(n)] or [IgnoreMember] on every public member, so mark it one or the other.";
|
||||
|
||||
private static string StringKeyMessage(string documentTypeName, string memberName) =>
|
||||
$"Cult document {documentTypeName} member {memberName} has a string [Key]; string keys are not supported; use integer [Key(n)].";
|
||||
|
||||
private static string NotWritableMessage(string documentTypeName, string memberName) =>
|
||||
$"Cult document {documentTypeName} member {memberName} is a readonly field; a persisted field must not be readonly.";
|
||||
private static string ReadonlyUnfilledMessage(string documentTypeName, string memberName, int slot) =>
|
||||
$"Cult document {documentTypeName} member {memberName} is a readonly field that no constructor fills; MessagePack writes it but never reads it back, so give the constructor MessagePack calls a parameter at position {slot}, drop readonly, or add [MessagePackObject(AllowPrivate = true)].";
|
||||
|
||||
private static string GetOnlyUnfilledMessage(string documentTypeName, string memberName, int slot) =>
|
||||
$"Cult document {documentTypeName} member {memberName} is a get-only property that no constructor fills; MessagePack writes it but never reads it back, so give the constructor MessagePack calls a parameter at position {slot}, add a setter, or mark it [IgnoreMember].";
|
||||
|
||||
private static string NonPublicSetterMessage(string documentTypeName, string memberName) =>
|
||||
$"Cult document {documentTypeName} member {memberName} has a non-public set accessor; MessagePack writes it but never reads it back without AllowPrivate, so make the setter public or add [MessagePackObject(AllowPrivate = true)].";
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ namespace GameCult.Caching.Tests
|
|||
public class DocumentShapeTests
|
||||
{
|
||||
private const string NonPublicSetter = "has a non-public set accessor; MessagePack writes it but never reads it back without AllowPrivate, so make the setter public or add [MessagePackObject(AllowPrivate = true)].";
|
||||
private const string Unkeyed = "has no [Key]; MessagePack requires [Key(n)] or [IgnoreMember] on every public member, so mark it one or the other.";
|
||||
private const string ReadonlyUnfilled = "is a readonly field that no constructor fills; MessagePack writes it but never reads it back, so give the constructor MessagePack calls a parameter at position {0}, drop readonly, or add [MessagePackObject(AllowPrivate = true)].";
|
||||
private const string DivergentOverride ="overrides {0} with a different [Key] or [IgnoreMember]; MessagePack reads the base declaration's, so an override must repeat or omit them.";
|
||||
|
||||
private static readonly ModuleBuilder Rejected = AssemblyBuilder
|
||||
|
|
@ -104,7 +106,7 @@ namespace GameCult.Caching.Tests
|
|||
var type = Document("Unkeyed");
|
||||
Field(type, "Name", typeof(string), Key(0));
|
||||
Field(type, "Count", typeof(int));
|
||||
Reject(type.CreateType()!, "Cult document Unkeyed member Count has no [Key]; every persisted member of a [CultDocument] type needs an explicit [Key(n)].");
|
||||
Reject(type.CreateType()!, "Cult document Unkeyed member Count " + Unkeyed);
|
||||
}
|
||||
|
||||
// MessagePack's DynamicObjectResolver reads the base declaration: it throws "key is duplicated" for this shape.
|
||||
|
|
@ -218,6 +220,81 @@ namespace GameCult.Caching.Tests
|
|||
Assert.That(Convert.ToHexString(Accept(new InternalSetterNote { Name = "n" })), Is.EqualTo("91A16E"));
|
||||
}
|
||||
|
||||
// MessagePack fills a get-only property through the longest constructor whose parameters take the keyed members by position.
|
||||
[Test]
|
||||
public void GetOnlyPropertyFilledByConstructor()
|
||||
{
|
||||
Assert.That(Convert.ToHexString(Accept(new GetOnlyNote("n", 2))), Is.EqualTo("92A16E02"));
|
||||
AssertSlots<GetOnlyNote>("0:Name", "1:Count");
|
||||
}
|
||||
|
||||
// The three-parameter constructor has no member at position 2, so MessagePack falls back to the one-parameter constructor.
|
||||
[Test]
|
||||
public void ReadonlyFieldFilledByConstructor()
|
||||
{
|
||||
Assert.That(Convert.ToHexString(Accept(new ReadonlyNote("n") { Count = 3 })), Is.EqualTo("92A16E03"));
|
||||
AssertSlots<ReadonlyNote>("0:Name", "1:Count");
|
||||
}
|
||||
|
||||
// Under AllowPrivate MessagePack writes a readonly field directly; no constructor has to fill it.
|
||||
[Test]
|
||||
public void ReadonlyFieldUnderAllowPrivate()
|
||||
{
|
||||
var sample = new ReadonlyPrivateNote();
|
||||
typeof(ReadonlyPrivateNote).GetField(nameof(ReadonlyPrivateNote.Name))!.SetValue(sample, "n");
|
||||
Assert.That(Convert.ToHexString(Accept(sample)), Is.EqualTo("91A16E"));
|
||||
Assert.That(((ReadonlyPrivateNote)CultDocumentMessagePackSerialization.DeserializeUntyped(
|
||||
typeof(ReadonlyPrivateNote), Accept(sample), CultDocumentRegistry.ForTypes(new[] { typeof(ReadonlyPrivateNote) }))).Name, Is.EqualTo("n"));
|
||||
AssertSlots<ReadonlyPrivateNote>("0:Name");
|
||||
}
|
||||
|
||||
// MessagePack throws "all public members must mark KeyAttribute or IgnoreMemberAttribute" for this shape.
|
||||
[Test]
|
||||
public void UnkeyedGetOnlyProperty()
|
||||
{
|
||||
var type = Document("UnkeyedGetter");
|
||||
Field(type, "Name", typeof(string), Key(0));
|
||||
GetOnlyProperty(type, "Upper");
|
||||
Reject(type.CreateType()!, "Cult document UnkeyedGetter member Upper " + Unkeyed);
|
||||
}
|
||||
|
||||
// MessagePack writes Label and loads it back as the default: only the parameterless constructor exists.
|
||||
[Test]
|
||||
public void GetOnlyPropertyNoConstructorFills()
|
||||
{
|
||||
var type = Document("GetterUnfilled");
|
||||
Field(type, "Name", typeof(string), Key(0));
|
||||
GetOnlyProperty(type, "Label", Key(1));
|
||||
Reject(type.CreateType()!, "Cult document GetterUnfilled member Label is a get-only property that no constructor fills; MessagePack writes it but never reads it back, so give the constructor MessagePack calls a parameter at position 1, add a setter, or mark it [IgnoreMember].");
|
||||
}
|
||||
|
||||
// Without AllowPrivate MessagePack writes Name and loads it back as the field initializer's value.
|
||||
[Test]
|
||||
public void ReadonlyFieldNoConstructorFills()
|
||||
{
|
||||
var type = Document("ReadonlyUnfilled");
|
||||
type.DefineField("Name", typeof(string), FieldAttributes.Public | FieldAttributes.InitOnly).SetCustomAttribute(Key(0));
|
||||
Reject(type.CreateType()!, "Cult document ReadonlyUnfilled member Name " + string.Format(ReadonlyUnfilled, 0));
|
||||
}
|
||||
|
||||
// MessagePack picks the one-parameter constructor, which fills Name but not Other; Other loads back as its default.
|
||||
[Test]
|
||||
public void ReadonlyFieldBeyondPickedConstructor()
|
||||
{
|
||||
var type = Document("ReadonlyBeyond");
|
||||
var name = type.DefineField("Name", typeof(string), FieldAttributes.Public | FieldAttributes.InitOnly);
|
||||
name.SetCustomAttribute(Key(0));
|
||||
type.DefineField("Other", typeof(string), FieldAttributes.Public | FieldAttributes.InitOnly).SetCustomAttribute(Key(1));
|
||||
var il = type.DefineConstructor(MethodAttributes.Public, CallingConventions.HasThis, new[] { typeof(string) }).GetILGenerator();
|
||||
il.Emit(OpCodes.Ldarg_0);
|
||||
il.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)!);
|
||||
il.Emit(OpCodes.Ldarg_0);
|
||||
il.Emit(OpCodes.Ldarg_1);
|
||||
il.Emit(OpCodes.Stfld, name);
|
||||
il.Emit(OpCodes.Ret);
|
||||
Reject(type.CreateType()!, "Cult document ReadonlyBeyond member Other " + string.Format(ReadonlyUnfilled, 1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StringKey()
|
||||
{
|
||||
|
|
@ -238,6 +315,23 @@ namespace GameCult.Caching.Tests
|
|||
return payload;
|
||||
}
|
||||
|
||||
private static void AssertSlots<T>(params string[] expected) where T : class =>
|
||||
Assert.That(CultDocumentRegistry.ForTypes(new[] { typeof(T) }).GetRequired<T>().ToCatalogEntry().Members.Select(member => $"{member.Slot}:{member.MemberName}"), Is.EqualTo(expected));
|
||||
|
||||
private static void GetOnlyProperty(TypeBuilder type, string name, params CustomAttributeBuilder[] attributes)
|
||||
{
|
||||
var backing = type.DefineField("_" + name, typeof(string), FieldAttributes.Private);
|
||||
backing.SetCustomAttribute(new CustomAttributeBuilder(typeof(IgnoreMemberAttribute).GetConstructor(Type.EmptyTypes)!, Array.Empty<object>()));
|
||||
var getter = type.DefineMethod("get_" + name, MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig, typeof(string), Type.EmptyTypes);
|
||||
var il = getter.GetILGenerator();
|
||||
il.Emit(OpCodes.Ldarg_0);
|
||||
il.Emit(OpCodes.Ldfld, backing);
|
||||
il.Emit(OpCodes.Ret);
|
||||
var property = type.DefineProperty(name, PropertyAttributes.None, typeof(string), null);
|
||||
property.SetGetMethod(getter);
|
||||
foreach (var attribute in attributes) property.SetCustomAttribute(attribute);
|
||||
}
|
||||
|
||||
private static void Reject(Type type, string expected) =>
|
||||
Assert.That(Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type }))!.Message, Is.EqualTo(expected));
|
||||
|
||||
|
|
@ -341,6 +435,39 @@ namespace GameCult.Caching.Tests
|
|||
[Key(0)] public string Name { get; internal set; } = "";
|
||||
}
|
||||
|
||||
[CultDocument("shape.get_only", "shape.get_only.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class GetOnlyNote
|
||||
{
|
||||
public GetOnlyNote(string name, int count)
|
||||
{
|
||||
Name = name;
|
||||
Count = count;
|
||||
}
|
||||
|
||||
[Key(0)] public string Name { get; }
|
||||
[Key(1)] public int Count { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("shape.readonly", "shape.readonly.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class ReadonlyNote
|
||||
{
|
||||
public ReadonlyNote() => Name = "";
|
||||
public ReadonlyNote(string name) => Name = name;
|
||||
public ReadonlyNote(string name, int count, string extra) => Name = name;
|
||||
|
||||
[Key(0)] public readonly string Name;
|
||||
[Key(1)] public int Count { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("shape.readonly_private", "shape.readonly_private.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class ReadonlyPrivateNote
|
||||
{
|
||||
[Key(0)] public readonly string Name = "init";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class OvrSameBase
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue