From c627200e4accddedb9558bfa1cbb3990504973cb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 6 May 2026 04:56:42 +0100 Subject: [PATCH] Preserve DatabaseEntry MessagePack key slots --- .../DatabaseEntryMessagePackGenerator.cs | 68 ++++++++++++------- .../README.md | 20 ++++-- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs index c5467d9c..779d605e 100644 --- a/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs +++ b/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs @@ -119,18 +119,24 @@ namespace GameCult.Caching.MessagePack.Generator sb.AppendLine(); var members = CollectMembers(type); - var ordered = OrderMembers(type, members); + var slots = AssignSlots(type, members); + var maxSlot = slots.Count == 0 ? -1 : slots.Max(m => m.slot); // Serialize sb.AppendLine($" public void Serialize(ref MessagePackWriter writer, {typeFull}? value, MessagePackSerializerOptions options)"); sb.AppendLine(" {"); sb.AppendLine(" if (value == null) { writer.WriteNil(); return; }"); - sb.AppendLine($" writer.WriteArrayHeader({ordered.Count});"); - for (int i = 0; i < ordered.Count; i++) + sb.AppendLine($" writer.WriteArrayHeader({maxSlot + 1});"); + for (int i = 0; i <= maxSlot; i++) { - var (name, tsym) = ordered[i]; - var tname = tsym.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var access = $"value.{name}"; + var member = slots.FirstOrDefault(m => m.slot == i); + if (member.name == null) + { + sb.AppendLine(" writer.WriteNil();"); + continue; + } + var tname = member.typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var access = $"value.{member.name}"; sb.AppendLine( tname == "global::System.Guid" ? $" options.Resolver.GetFormatterWithVerify().Serialize(ref writer, {access}, options);" @@ -144,21 +150,26 @@ namespace GameCult.Caching.MessagePack.Generator sb.AppendLine(" {"); sb.AppendLine(" if (reader.TryReadNil()) return null;"); sb.AppendLine($" var count = reader.ReadArrayHeader();"); - sb.AppendLine($" if (count != {ordered.Count}) throw new MessagePackSerializationException($\"Invalid {type.Name} length {{count}}, expected {ordered.Count}\");"); - for (int i = 0; i < ordered.Count; i++) + sb.AppendLine($" var obj = new {typeFull}();"); + sb.AppendLine(" for (var i = 0; i < count; i++)"); + sb.AppendLine(" {"); + sb.AppendLine(" switch (i)"); + sb.AppendLine(" {"); + foreach (var (slot, name, tsym) in slots) { - var (_, tsym) = ordered[i]; var tname = tsym.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + sb.AppendLine($" case {slot}:"); sb.AppendLine( tname == "global::System.Guid" - ? $" var v{i} = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options);" - : $" var v{i} = options.Resolver.GetFormatterWithVerify<{tname}>().Deserialize(ref reader, options);"); - } - sb.AppendLine($" var obj = new {typeFull}();"); - for (int i = 0; i < ordered.Count; i++) - { - sb.AppendLine($" obj.{ordered[i].name} = v{i};"); + ? $" obj.{name} = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options);" + : $" obj.{name} = options.Resolver.GetFormatterWithVerify<{tname}>().Deserialize(ref reader, options);"); + sb.AppendLine(" break;"); } + sb.AppendLine(" default:"); + sb.AppendLine(" reader.Skip();"); + sb.AppendLine(" break;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); sb.AppendLine(" return obj;"); sb.AppendLine(" }"); @@ -190,29 +201,34 @@ namespace GameCult.Caching.MessagePack.Generator return members; } - private static List<(string name, ITypeSymbol typeSymbol)> OrderMembers( + private static List<(int slot, string name, ITypeSymbol typeSymbol)> AssignSlots( INamedTypeSymbol type, List<(string name, ITypeSymbol typeSymbol, int? key, bool ignore)> members) { members = members.Where(m => !m.ignore).ToList(); - var ordered = new List<(string, ITypeSymbol)>(); + var slots = new List<(int, string, ITypeSymbol)>(); var id = FindIdMember(type); if (id != null) { - ordered.Add((id.Value.name, id.Value.typeSymbol)); + slots.Add((0, id.Value.name, id.Value.typeSymbol)); members.RemoveAll(m => m.name == id.Value.name); } - var keyed = members.Where(m => m.key.HasValue).OrderBy(m => m.key.Value) - .Select(m => (m.name, m.typeSymbol)).ToList(); - var nonKeyed = members.Where(m => !m.key.HasValue) - .Select(m => (m.name, m.typeSymbol)).ToList(); + foreach (var member in members.Where(m => m.key.HasValue).OrderBy(m => m.key!.Value)) + { + slots.Add((member.key!.Value, member.name, member.typeSymbol)); + } - ordered.AddRange(keyed); - ordered.AddRange(nonKeyed); - return ordered; + var nextSlot = slots.Count == 0 ? 0 : slots.Max(s => s.Item1) + 1; + foreach (var member in members.Where(m => !m.key.HasValue)) + { + slots.Add((nextSlot, member.name, member.typeSymbol)); + nextSlot++; + } + + return slots.OrderBy(s => s.Item1).ToList(); } private static int? GetKeyValue(ISymbol sym) diff --git a/src/GameCult.Caching.MessagePack.Generator/README.md b/src/GameCult.Caching.MessagePack.Generator/README.md index 57fb314f..79bfd29a 100644 --- a/src/GameCult.Caching.MessagePack.Generator/README.md +++ b/src/GameCult.Caching.MessagePack.Generator/README.md @@ -19,13 +19,23 @@ For each discovered concrete `DatabaseEntry` subtype, the generator emits: - a formatter type - a resolver mapping entry -Member ordering is: +Member slot assignment is: -1. `ID` first -2. members with `[Key(...)]`, ordered by key -3. remaining non-ignored instance fields and properties +1. `ID` in slot `0` +2. members with `[Key(...)]` in their exact key slot +3. remaining non-ignored instance fields and properties appended after the + highest occupied slot as legacy convenience -That gives predictable binary layout while still supporting manual `[Key]` ordering. +The generated formatter writes a MessagePack array sized to the highest occupied +slot and writes nil for unused gaps. Deserialization tolerates shorter older +arrays by leaving missing members at their constructor/field-initializer values, +and skips unknown extra slots. That makes `[Key(n)]` a durable wire contract: +deleted fields can leave their slot reserved and new fields can use new keys +without a store migration. + +For schema-stable cache entries, annotate every persisted member. Unkeyed +members are supported for older code, but they are not a good long-term wire +contract because source member order is not a schema. ## Example