The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).
- Accessors are named per document, so documents sharing a [CultName] or
[CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
re-keyed override and serializes an override marked [IgnoreMember]).
An override that changes them is refused in both paths; an override
that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
generated codecs and declared resolvers; deleted. CultNetDocumentBinding
and CultMeshSnapshots route through the untyped path with their registry.
DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
13 KiB
C#
399 lines
13 KiB
C#
using System;
|
|
using System.Buffers;
|
|
using System.Collections.Concurrent;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using GameCult.Caching;
|
|
using MessagePack;
|
|
using MessagePack.Formatters;
|
|
using MessagePack.Resolvers;
|
|
|
|
namespace GameCult.Caching.MessagePack;
|
|
|
|
public sealed class CultDocumentResolver : IFormatterResolver
|
|
{
|
|
public static readonly CultDocumentResolver Instance = new();
|
|
private CultDocumentResolver() { }
|
|
|
|
public IMessagePackFormatter<T>? GetFormatter<T>()
|
|
{
|
|
var type = typeof(T);
|
|
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
|
|
{
|
|
var formatterType = typeof(global::GameCult.Caching.CultRecordRefFormatter<>).MakeGenericType(type.GetGenericArguments()[0]);
|
|
return (IMessagePackFormatter<T>)Activator.CreateInstance(formatterType)!;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public static class CultDocumentMessagePackSerialization
|
|
{
|
|
private const int PersistedRecordFieldCount = 4;
|
|
private const int SchemaCatalogEntryFieldCount = 7;
|
|
private const int SchemaCatalogMemberFieldCount = 8;
|
|
private const int StoreSnapshotFieldCount = 3;
|
|
|
|
public static readonly MessagePackSerializerOptions Options = Compose(Array.Empty<IFormatterResolver>());
|
|
|
|
private static readonly ConcurrentDictionary<Assembly, MessagePackSerializerOptions> AssemblyOptions = new();
|
|
|
|
public static MessagePackSerializerOptions OptionsFor(Assembly documentAssembly)
|
|
{
|
|
if (documentAssembly == null) throw new ArgumentNullException(nameof(documentAssembly));
|
|
return AssemblyOptions.GetOrAdd(documentAssembly, static assembly =>
|
|
{
|
|
var resolvers = assembly.GetCustomAttributes<CultCacheFormatterResolverAttribute>()
|
|
.Select(attribute => CreateResolver(attribute.ResolverType))
|
|
.ToArray();
|
|
return resolvers.Length == 0 ? Options : Compose(resolvers);
|
|
});
|
|
}
|
|
|
|
private static MessagePackSerializerOptions Compose(IFormatterResolver[] consumerResolvers)
|
|
{
|
|
return MessagePackSerializerOptions.Standard
|
|
.WithResolver(CompositeResolver.Create(
|
|
consumerResolvers.Append(CultDocumentResolver.Instance).Append(StandardResolver.Instance).ToArray()))
|
|
.WithSecurity(CultMessagePackSecurity.Instance);
|
|
}
|
|
|
|
private static IFormatterResolver CreateResolver(Type resolverType)
|
|
{
|
|
var instance = resolverType.GetField("Instance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null)
|
|
?? resolverType.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null)
|
|
?? (resolverType.GetConstructor(Type.EmptyTypes) != null ? Activator.CreateInstance(resolverType) : null);
|
|
return instance as IFormatterResolver
|
|
?? throw new InvalidOperationException(
|
|
$"Formatter resolver {resolverType.FullName} needs a public static Instance or a public parameterless constructor and must implement {nameof(IFormatterResolver)}.");
|
|
}
|
|
|
|
public static byte[] SerializeUntyped(object value, Type type)
|
|
{
|
|
return SerializeUntyped(value, type, CultDocumentRegistry.Shared);
|
|
}
|
|
|
|
public static byte[] SerializeUntyped(object value, Type type, CultDocumentRegistry registry)
|
|
{
|
|
if (registry == null) throw new ArgumentNullException(nameof(registry));
|
|
if (value != null)
|
|
{
|
|
var descriptor = registry.GetRequired(type);
|
|
if (descriptor.GeneratedPayloadSerializer != null)
|
|
{
|
|
return descriptor.GeneratedPayloadSerializer(value);
|
|
}
|
|
}
|
|
|
|
return MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly));
|
|
}
|
|
|
|
public static object DeserializeUntyped(Type type, byte[] payload)
|
|
{
|
|
return DeserializeUntyped(type, payload, CultDocumentRegistry.Shared);
|
|
}
|
|
|
|
public static object DeserializeUntyped(Type type, byte[] payload, CultDocumentRegistry registry)
|
|
{
|
|
if (registry == null) throw new ArgumentNullException(nameof(registry));
|
|
var descriptor = registry.GetRequired(type);
|
|
if (descriptor.GeneratedPayloadDeserializer != null)
|
|
{
|
|
return descriptor.GeneratedPayloadDeserializer(payload);
|
|
}
|
|
|
|
return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type.Assembly))
|
|
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
|
|
}
|
|
|
|
public static byte[] SerializePersistedRecord(CultPersistedRecord record)
|
|
{
|
|
var buffer = new global::System.Buffers.ArrayBufferWriter<byte>();
|
|
var writer = new MessagePackWriter(buffer);
|
|
WritePersistedRecord(ref writer, record);
|
|
writer.Flush();
|
|
return buffer.WrittenSpan.ToArray();
|
|
}
|
|
|
|
public static CultPersistedRecord DeserializePersistedRecord(byte[] payload)
|
|
{
|
|
var reader = new MessagePackReader(payload);
|
|
return ReadPersistedRecord(ref reader);
|
|
}
|
|
|
|
public static byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot)
|
|
{
|
|
var buffer = new global::System.Buffers.ArrayBufferWriter<byte>();
|
|
var writer = new MessagePackWriter(buffer);
|
|
writer.WriteArrayHeader(StoreSnapshotFieldCount);
|
|
writer.Write(snapshot.FormatVersion);
|
|
writer.WriteArrayHeader(snapshot.SchemaCatalog.Length);
|
|
foreach (var entry in snapshot.SchemaCatalog)
|
|
{
|
|
WriteSchemaCatalogEntry(ref writer, entry);
|
|
}
|
|
|
|
writer.WriteArrayHeader(snapshot.Records.Length);
|
|
foreach (var record in snapshot.Records)
|
|
{
|
|
WritePersistedRecord(ref writer, record);
|
|
}
|
|
|
|
writer.Flush();
|
|
return buffer.WrittenSpan.ToArray();
|
|
}
|
|
|
|
public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload)
|
|
{
|
|
var reader = new MessagePackReader(payload);
|
|
var fieldCount = reader.ReadArrayHeader();
|
|
var snapshot = new CultPersistedStoreSnapshot();
|
|
|
|
if (fieldCount > 0)
|
|
{
|
|
snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1";
|
|
}
|
|
|
|
if (fieldCount > 1)
|
|
{
|
|
var catalogCount = reader.ReadArrayHeader();
|
|
snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount];
|
|
for (var index = 0; index < catalogCount; index++)
|
|
{
|
|
snapshot.SchemaCatalog[index] = ReadSchemaCatalogEntry(ref reader);
|
|
}
|
|
}
|
|
|
|
if (fieldCount > 2)
|
|
{
|
|
var recordCount = reader.ReadArrayHeader();
|
|
snapshot.Records = new CultPersistedRecord[recordCount];
|
|
for (var index = 0; index < recordCount; index++)
|
|
{
|
|
snapshot.Records[index] = ReadPersistedRecord(ref reader);
|
|
}
|
|
}
|
|
|
|
for (var index = StoreSnapshotFieldCount; index < fieldCount; index++)
|
|
{
|
|
reader.Skip();
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record)
|
|
{
|
|
writer.WriteArrayHeader(PersistedRecordFieldCount);
|
|
writer.Write(record.Key);
|
|
writer.Write(record.SchemaId);
|
|
writer.Write(record.StoredAt);
|
|
writer.Write(record.Payload);
|
|
}
|
|
|
|
private static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader)
|
|
{
|
|
var fieldCount = reader.ReadArrayHeader();
|
|
var record = new CultPersistedRecord();
|
|
|
|
if (fieldCount > 0)
|
|
{
|
|
record.Key = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 1)
|
|
{
|
|
record.SchemaId = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 2)
|
|
{
|
|
record.StoredAt = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 3)
|
|
{
|
|
record.Payload = reader.ReadBytes()?.ToArray() ?? Array.Empty<byte>();
|
|
}
|
|
|
|
for (var index = PersistedRecordFieldCount; index < fieldCount; index++)
|
|
{
|
|
reader.Skip();
|
|
}
|
|
|
|
return record;
|
|
}
|
|
|
|
private static void WriteSchemaCatalogEntry(ref MessagePackWriter writer, CultSchemaCatalogEntry entry)
|
|
{
|
|
writer.WriteArrayHeader(SchemaCatalogEntryFieldCount);
|
|
writer.Write(entry.SchemaId);
|
|
writer.Write(entry.SchemaName);
|
|
writer.Write(entry.SchemaVersion);
|
|
writer.Write(entry.ContentHash);
|
|
writer.Write(entry.CanonicalSchemaJson);
|
|
writer.WriteArrayHeader(entry.CompatibleSchemaIds.Length);
|
|
foreach (var schemaId in entry.CompatibleSchemaIds)
|
|
{
|
|
writer.Write(schemaId);
|
|
}
|
|
|
|
writer.WriteArrayHeader(entry.Members.Length);
|
|
foreach (var member in entry.Members)
|
|
{
|
|
WriteSchemaCatalogMember(ref writer, member);
|
|
}
|
|
}
|
|
|
|
private static CultSchemaCatalogEntry ReadSchemaCatalogEntry(ref MessagePackReader reader)
|
|
{
|
|
var fieldCount = reader.ReadArrayHeader();
|
|
var entry = new CultSchemaCatalogEntry();
|
|
|
|
if (fieldCount > 0)
|
|
{
|
|
entry.SchemaId = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 1)
|
|
{
|
|
entry.SchemaName = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 2)
|
|
{
|
|
entry.SchemaVersion = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 3)
|
|
{
|
|
entry.ContentHash = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 4)
|
|
{
|
|
entry.CanonicalSchemaJson = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 5)
|
|
{
|
|
var compatibleCount = reader.ReadArrayHeader();
|
|
entry.CompatibleSchemaIds = new string[compatibleCount];
|
|
for (var index = 0; index < compatibleCount; index++)
|
|
{
|
|
entry.CompatibleSchemaIds[index] = reader.ReadString() ?? string.Empty;
|
|
}
|
|
}
|
|
|
|
if (fieldCount > 6)
|
|
{
|
|
var memberCount = reader.ReadArrayHeader();
|
|
entry.Members = new CultSchemaMemberCatalogEntry[memberCount];
|
|
for (var index = 0; index < memberCount; index++)
|
|
{
|
|
entry.Members[index] = ReadSchemaCatalogMember(ref reader);
|
|
}
|
|
}
|
|
|
|
for (var index = SchemaCatalogEntryFieldCount; index < fieldCount; index++)
|
|
{
|
|
reader.Skip();
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
private static void WriteSchemaCatalogMember(ref MessagePackWriter writer, CultSchemaMemberCatalogEntry member)
|
|
{
|
|
writer.WriteArrayHeader(SchemaCatalogMemberFieldCount);
|
|
writer.Write(member.Slot);
|
|
writer.Write(member.MemberName);
|
|
writer.Write(member.TypeName);
|
|
writer.Write(member.IsReference);
|
|
writer.Write(member.IsMany);
|
|
writer.Write(member.TargetSchemaName);
|
|
writer.Write(member.IsName);
|
|
writer.Write(member.IndexAlias);
|
|
}
|
|
|
|
private static CultSchemaMemberCatalogEntry ReadSchemaCatalogMember(ref MessagePackReader reader)
|
|
{
|
|
var fieldCount = reader.ReadArrayHeader();
|
|
var member = new CultSchemaMemberCatalogEntry();
|
|
|
|
if (fieldCount > 0)
|
|
{
|
|
member.Slot = reader.ReadInt32();
|
|
}
|
|
|
|
if (fieldCount > 1)
|
|
{
|
|
member.MemberName = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 2)
|
|
{
|
|
member.TypeName = reader.ReadString() ?? string.Empty;
|
|
}
|
|
|
|
if (fieldCount > 3)
|
|
{
|
|
member.IsReference = reader.ReadBoolean();
|
|
}
|
|
|
|
if (fieldCount > 4)
|
|
{
|
|
member.IsMany = reader.ReadBoolean();
|
|
}
|
|
|
|
if (fieldCount > 5)
|
|
{
|
|
member.TargetSchemaName = reader.ReadString();
|
|
}
|
|
|
|
if (fieldCount > 6)
|
|
{
|
|
member.IsName = reader.ReadBoolean();
|
|
}
|
|
|
|
if (fieldCount > 7)
|
|
{
|
|
member.IndexAlias = reader.ReadString();
|
|
}
|
|
|
|
for (var index = SchemaCatalogMemberFieldCount; index < fieldCount; index++)
|
|
{
|
|
reader.Skip();
|
|
}
|
|
|
|
return member;
|
|
}
|
|
}
|
|
|
|
public class SingleFileMessagePackBackingStore : SingleFileBackingStore
|
|
{
|
|
public SingleFileMessagePackBackingStore(string filePath, bool readOnly = false) : base(filePath, readOnly)
|
|
{
|
|
}
|
|
|
|
protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot)
|
|
{
|
|
return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot);
|
|
}
|
|
|
|
protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
|
|
{
|
|
return CultDocumentMessagePackSerialization.DeserializeSnapshot(data);
|
|
}
|
|
|
|
protected override byte[] SerializePayload(object document)
|
|
{
|
|
return CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), Registry);
|
|
}
|
|
|
|
protected override object DeserializePayload(Type documentType, byte[] payload)
|
|
{
|
|
return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
|
|
}
|
|
}
|