Skip to content

Commit 85f87c1

Browse files
committed
no-mistakes(review): Serialize map VO enums as symbols via JsonStringEnumConverter
1 parent 38afbe4 commit 85f87c1

2 files changed

Lines changed: 104 additions & 1 deletion

File tree

‎server/csharp/MetaObjects.Codegen.Tests/DbContextCompileTests.cs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
using MetaObjects.Codegen.Generators;
5555
using MetaObjects.Loader;
5656
using MetaObjects.Meta;
57+
using System.Reflection;
5758
using Xunit;
5859

5960
namespace MetaObjects.Codegen.Tests;
@@ -218,6 +219,105 @@ public void Generated_AppDbContext_and_entities_compile_against_EF_Core_8()
218219
Assert.Contains("[Column(OrderNames.StatusColumn)]", orderSrc);
219220
}
220221

222+
// The map's value object is serialized by the MapJsonb helper's shared JsonSerializerOptions,
223+
// and System.Text.Json writes enums as NUMBERS by default — so a field.enum member of the
224+
// map's @objectRef value object persisted as its ORDINAL, while the same member of the same
225+
// value object reached through a field.object ToJson column persisted as its SYMBOL
226+
// (JsonEnumConversions: inside a JSON document there is no column, so the symbol is written
227+
// unconditionally and @intValueMap is deliberately not consulted). TypeScript, Java, Kotlin
228+
// and Python all write the symbol, so the ordinal form was a silent cross-port wire break.
229+
// This EXECUTES the emitted code: the generated files are compiled against real EF Core 8,
230+
// the assembly is loaded, and the emitted Converter<Address>'s own delegates are invoked
231+
// over a map value carrying BOTH a string-backed enum member (kind) and an int-backed one
232+
// (tier) — Address here carries exactly those two.
233+
[Fact]
234+
public void MapJsonb_converter_persists_enum_members_as_their_symbol()
235+
{
236+
var ctx = Ctx(Load());
237+
var entityFiles = new EntityGenerator().Generate(ctx).ToList();
238+
var dbContextFiles = new DbContextGenerator().Generate(ctx).ToList();
239+
var namesFiles = new NamesGenerator().Generate(ctx).ToList();
240+
241+
var dbctx = Assert.Single(dbContextFiles).Content;
242+
Assert.Contains("new System.Text.Json.Serialization.JsonStringEnumConverter()", dbctx);
243+
244+
var allSources = entityFiles.Concat(dbContextFiles).Concat(namesFiles).ToList();
245+
var trees = allSources.Select(f => CSharpSyntaxTree.ParseText(
246+
f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))).ToList();
247+
var comp = CSharpCompilation.Create(
248+
"mapjsonenum_" + Guid.NewGuid().ToString("N"),
249+
trees, BuildReferences(),
250+
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
251+
252+
var errors = comp.GetDiagnostics()
253+
.Where(d => d.Severity == DiagnosticSeverity.Error)
254+
.Select(d => $"{d.Id}: {d.GetMessage()}")
255+
.ToList();
256+
Assert.True(
257+
errors.Count == 0,
258+
"Generated entity + AppDbContext should compile against EF Core 8, but got errors:\n"
259+
+ string.Join("\n", errors));
260+
261+
using var pe = new MemoryStream();
262+
var emit = comp.Emit(pe);
263+
Assert.True(
264+
emit.Success,
265+
string.Join("; ", emit.Diagnostics
266+
.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.GetMessage())));
267+
var asm = Assembly.Load(pe.ToArray());
268+
269+
var appDbContext = asm.GetType("Acme.Generated.AppDbContext");
270+
Assert.NotNull(appDbContext);
271+
var helper = appDbContext.GetNestedType("MapJsonb", BindingFlags.NonPublic);
272+
Assert.NotNull(helper);
273+
var converterOf = helper.GetMethod("Converter", BindingFlags.NonPublic | BindingFlags.Static);
274+
Assert.NotNull(converterOf);
275+
276+
var addressType = asm.GetType("Acme.Generated.Address");
277+
Assert.NotNull(addressType);
278+
// A non-required VO enum emits as Nullable<TEnum> — unwrap to the underlying enum
279+
// before parsing. The stored jsonb keys are the metadata field names, pinned by the
280+
// POCO's [JsonPropertyName] to the cross-port wire contract (Program D).
281+
var kind = addressType.GetProperty("Kind");
282+
Assert.NotNull(kind);
283+
var tier = addressType.GetProperty("Tier");
284+
Assert.NotNull(tier);
285+
var kindEnum = Nullable.GetUnderlyingType(kind.PropertyType) ?? kind.PropertyType;
286+
var tierEnum = Nullable.GetUnderlyingType(tier.PropertyType) ?? tier.PropertyType;
287+
Assert.True(kindEnum.IsEnum);
288+
Assert.True(tierEnum.IsEnum);
289+
290+
var address = Activator.CreateInstance(addressType)!;
291+
var homeKind = Enum.Parse(kindEnum, "HOME");
292+
var tierA = Enum.Parse(tierEnum, "A");
293+
kind.SetValue(address, homeKind);
294+
tier.SetValue(address, tierA);
295+
var map = (System.Collections.IDictionary)Activator.CreateInstance(
296+
typeof(Dictionary<,>).MakeGenericType(typeof(string), addressType))!;
297+
map["hq"] = address;
298+
299+
var converter = converterOf.MakeGenericMethod(addressType).Invoke(null, null)!;
300+
// DeclaredOnly: ValueConverter<TModel, TProvider> re-declares these properties with
301+
// `new` over the non-generic base's same-named ones, so a plain GetProperty is ambiguous.
302+
var toProvider = (Delegate)converter.GetType()
303+
.GetProperty("ConvertToProvider", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)!
304+
.GetValue(converter)!;
305+
var json = (string)toProvider.DynamicInvoke(map)!;
306+
307+
Assert.Contains("\"kind\":\"HOME\"", json);
308+
Assert.Contains("\"tier\":\"A\"", json);
309+
Assert.DoesNotContain("\"kind\":0", json);
310+
Assert.DoesNotContain("\"tier\":0", json);
311+
312+
var fromProvider = (Delegate)converter.GetType()
313+
.GetProperty("ConvertFromProvider", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)!
314+
.GetValue(converter)!;
315+
var roundTripped = (System.Collections.IDictionary)fromProvider.DynamicInvoke(json)!;
316+
var back = roundTripped["hq"]!;
317+
Assert.Equal(homeKind, kind.GetValue(back));
318+
Assert.Equal(tierA, tier.GetValue(back));
319+
}
320+
221321
// #214 review defects [0] + [1] — the write-through read model (<Entity>View) must carry
222322
// BOTH a per-field TYPE-converter column (field.uri docUrl) AND a non-derived jsonb
223323
// value-object column (field.object @storage:jsonb billingAddress), and the DbContext must

‎server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,10 @@ private static void EmitMapJsonbHelper(StringBuilder sb)
721721
sb.AppendLine(" /// </summary>");
722722
sb.AppendLine($" private static class {MapJsonbHelperName}");
723723
sb.AppendLine(" {");
724-
sb.AppendLine(" private static readonly System.Text.Json.JsonSerializerOptions Options = new();");
724+
sb.AppendLine(" private static readonly System.Text.Json.JsonSerializerOptions Options = new()");
725+
sb.AppendLine(" {");
726+
sb.AppendLine(" Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }");
727+
sb.AppendLine(" };");
725728
sb.AppendLine();
726729
sb.AppendLine(" internal static Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<");
727730
sb.AppendLine(" System.Collections.Generic.Dictionary<string, TValue>, string> Converter<TValue>() => new(");

0 commit comments

Comments
 (0)