@@ -115,7 +115,7 @@ public virtual IEnumerable<EmittedFile> Generate(GenContext ctx)
115115 // explicit mapping makes EF agree with the jsonb column the TS-owned migration
116116 // creates (ADR-0015).
117117 foreach ( var f in p . Fields ( ) . Where ( f => f . SubType == FIELD_SUBTYPE_MAP ) )
118- modelLines . Add ( MapJsonbConfig ( name , f , ctx ) ) ;
118+ modelLines . Add ( MapJsonbConfig ( name , p , f , ctx ) ) ;
119119 }
120120 foreach ( var e in objects . Where ( o => o . IsEntity ( ) && ! o . IsReadOnlyProjection ( ) ) )
121121 {
@@ -641,9 +641,9 @@ private static void EmitUnmappedEnumHelper(StringBuilder sb)
641641 /// (<see cref="EmitFieldTypeConfig"/>) and the read-only-projection path, which both emit
642642 /// a <c>Dictionary<string, V></c> property and so both need the mapping.
643643 /// </summary>
644- private static string MapJsonbConfig ( string className , MetaField f , GenContext ctx ) =>
644+ private static string MapJsonbConfig ( string className , MetaObject owner , MetaField f , GenContext ctx ) =>
645645 $ " modelBuilder.Entity<{ className } >().Property(x => x.{ CSharpNaming . Pascal ( f . Name ) } )"
646- + MapJsonbSuffix ( f , ctx ) + ";" ;
646+ + MapJsonbSuffix ( owner , f , ctx ) + ";" ;
647647
648648 /// <summary>
649649 /// The column-type + converter/comparer suffix every <c>field.map</c> mapping ends in, whatever
@@ -652,11 +652,18 @@ private static string MapJsonbConfig(string className, MetaField f, GenContext c
652652 /// a converter change fixed in one tier only is exactly the failure the flattened tier's old
653653 /// known-gap entry described.
654654 /// </summary>
655- private static string MapJsonbSuffix ( MetaField f , GenContext ctx )
655+ private static string MapJsonbSuffix ( MetaObject owner , MetaField f , GenContext ctx )
656656 {
657657 var valueType = MapValueTypeRef ( f , ctx ) ;
658+ // The SAME requiredness predicate the property emission uses (MapProperty), so the
659+ // converter's nullability annotation always matches the property it converts: EF Core
660+ // 8's nullability-aware HasConversion signature checks the converter's model type
661+ // against the property's own annotation, and a mismatch is a CS8620 warning.
662+ var converter = CSharpNaming . IsRequired ( owner , f )
663+ ? $ "{ MapJsonbHelperName } .RequiredConverter<{ valueType } >()"
664+ : $ "{ MapJsonbHelperName } .Converter<{ valueType } >()";
658665 return $ ".HasColumnType(\" jsonb\" ).HasConversion("
659- + $ "{ MapJsonbHelperName } .Converter< { valueType } >() , { MapJsonbHelperName } .Comparer<{ valueType } >())";
666+ + $ "{ converter } , { MapJsonbHelperName } .Comparer<{ valueType } >())";
660667 }
661668
662669 /// <summary>Name of the generated jsonb (de)serialization helper the field.map configs use.</summary>
@@ -710,6 +717,12 @@ private static string MapValueTypeRef(MetaField f, GenContext ctx)
710717 /// <para>Fully qualified throughout: the generated file's usings are a fixed set
711718 /// (<see cref="EmitUsings"/>), and widening it would change byte-identical output for
712719 /// every model.</para>
720+ /// <para>The converter/comparer type arguments are the NULLABLE dictionary: a
721+ /// non-required map's property is nullable (nullability follows the column), and EF
722+ /// Core 8's nullability-aware <c>HasConversion</c> signature expects the converter's
723+ /// model type to match. Annotation-only — EF passes null through a value converter
724+ /// untranslated and never invokes the expressions on it, so the lambdas' bodies are
725+ /// unchanged.</para>
713726 /// </remarks>
714727 private static void EmitMapJsonbHelper ( StringBuilder sb )
715728 {
@@ -727,14 +740,24 @@ private static void EmitMapJsonbHelper(StringBuilder sb)
727740 sb . AppendLine ( " };" ) ;
728741 sb . AppendLine ( ) ;
729742 sb . AppendLine ( " internal static Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<" ) ;
730- sb . AppendLine ( " System.Collections.Generic.Dictionary<string, TValue>, string> Converter<TValue>() => new(" ) ;
743+ sb . AppendLine ( " System.Collections.Generic.Dictionary<string, TValue>, string> RequiredConverter<TValue>() => new(" ) ;
744+ sb . AppendLine ( " v => System.Text.Json.JsonSerializer.Serialize(v, Options)," ) ;
745+ sb . AppendLine ( " v => System.Text.Json.JsonSerializer.Deserialize<" ) ;
746+ sb . AppendLine ( " System.Collections.Generic.Dictionary<string, TValue>>(v, Options)!);" ) ;
747+ sb . AppendLine ( ) ;
748+ sb . AppendLine ( " /// <summary>Same conversion as RequiredConverter, over the NULLABLE dictionary a" ) ;
749+ sb . AppendLine ( " /// non-required map's property carries — keep the two bodies identical. EF Core's" ) ;
750+ sb . AppendLine ( " /// nullability-aware HasConversion checks the converter's model type against the" ) ;
751+ sb . AppendLine ( " /// property's own annotation, so one factory cannot serve both arms.</summary>" ) ;
752+ sb . AppendLine ( " internal static Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<" ) ;
753+ sb . AppendLine ( " System.Collections.Generic.Dictionary<string, TValue>?, string> Converter<TValue>() => new(" ) ;
731754 sb . AppendLine ( " v => System.Text.Json.JsonSerializer.Serialize(v, Options)," ) ;
732755 sb . AppendLine ( " v => System.Text.Json.JsonSerializer.Deserialize<" ) ;
733756 sb . AppendLine ( " System.Collections.Generic.Dictionary<string, TValue>>(v, Options)!);" ) ;
734757 sb . AppendLine ( ) ;
735758 sb . AppendLine ( " internal static Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<" ) ;
736- sb . AppendLine ( " System.Collections.Generic.Dictionary<string, TValue>> Comparer<TValue>() => new(" ) ;
737- sb . AppendLine ( " (a, b) => Eq(a, b), v => Hash(v), v => Snap(v)! );" ) ;
759+ sb . AppendLine ( " System.Collections.Generic.Dictionary<string, TValue>? > Comparer<TValue>() => new(" ) ;
760+ sb . AppendLine ( " (a, b) => Eq(a, b), v => Hash(v), v => Snap(v));" ) ;
738761 sb . AppendLine ( ) ;
739762 sb . AppendLine ( " /// <summary>Order-INDEPENDENT equality: a dictionary rebuilt in a different key" ) ;
740763 sb . AppendLine ( " /// order holds the same value, and comparing serialized JSON would call it changed" ) ;
@@ -908,7 +931,7 @@ private void EmitFieldTypeConfig(
908931 // the generated code cannot make on a consumer's behalf. The COMPARER is not optional --
909932 // see EmitMapJsonbHelper.
910933 foreach ( var f in fieldList . Where ( f => f . SubType == FIELD_SUBTYPE_MAP ) )
911- modelLines . Add ( MapJsonbConfig ( className , f , ctx ) ) ;
934+ modelLines . Add ( MapJsonbConfig ( className , entity , f , ctx ) ) ;
912935
913936 foreach ( var f in fieldList . Where ( f => f . SubType == FIELD_SUBTYPE_ENUM ) )
914937 {
@@ -1114,7 +1137,7 @@ private void EmitFieldTypeConfig(
11141137 if ( nf . SubType == FIELD_SUBTYPE_MAP )
11151138 {
11161139 sb . AppendLine ( $ " b.Property(p => p.{ CSharpNaming . Pascal ( nf . Name ) } )"
1117- + $ ".HasColumnName(\" { nestedColAny } \" )" + MapJsonbSuffix ( nf , ctx ) + ";" ) ;
1140+ + $ ".HasColumnName(\" { nestedColAny } \" )" + MapJsonbSuffix ( vo , nf , ctx ) + ";" ) ;
11181141 continue ;
11191142 }
11201143
0 commit comments