Skip to content

Commit 70d3492

Browse files
Copilotrenemadsen
andcommitted
Restore custom JSON handling implementation (.ToJson support)
Co-authored-by: renemadsen <76994+renemadsen@users.noreply.github.com>
1 parent 90f752b commit 70d3492

6 files changed

Lines changed: 301 additions & 3 deletions

File tree

src/EFCore.MySql/Metadata/Conventions/MySqlConventionSetBuilder.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ public override ConventionSet CreateConventionSet()
4444
conventionSet.PropertyAddedConventions.Add(new ColumnCharSetAttributeConvention(Dependencies));
4545
conventionSet.PropertyAddedConventions.Add(new ColumnCollationAttributeConvention(Dependencies));
4646

47+
// Add JSON column convention to set store type for JSON-mapped complex properties
48+
var jsonColumnConvention = new MySqlJsonColumnConvention(Dependencies, RelationalDependencies);
49+
conventionSet.ComplexPropertyAddedConventions.Add(jsonColumnConvention);
50+
conventionSet.ComplexPropertyAnnotationChangedConventions.Add(jsonColumnConvention);
51+
conventionSet.ComplexTypeAnnotationChangedConventions.Add(jsonColumnConvention);
52+
4753
var valueGenerationConvention = new MySqlValueGenerationConvention(Dependencies, RelationalDependencies);
4854
ReplaceConvention(conventionSet.EntityTypeBaseTypeChangedConventions, valueGenerationConvention);
4955
ReplaceConvention(conventionSet.EntityTypeAnnotationChangedConventions, (RelationalValueGenerationConvention)valueGenerationConvention);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) Pomelo Foundation. All rights reserved.
2+
// Licensed under the MIT. See LICENSE in the project root for license information.
3+
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.EntityFrameworkCore.Metadata;
6+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
7+
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
8+
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
9+
10+
namespace Pomelo.EntityFrameworkCore.MySql.Metadata.Conventions
11+
{
12+
/// <summary>
13+
/// A convention that configures the column type as "json" for complex properties
14+
/// that are mapped to JSON columns in the database.
15+
///
16+
/// Works for both MySQL (5.7.8+, native JSON type) and MariaDB (10.2.4+, JSON as LONGTEXT alias).
17+
/// Both databases accept "json" as the column type in DDL, with different underlying storage:
18+
/// - MySQL: Binary JSON format with optimized storage and indexing
19+
/// - MariaDB: LONGTEXT with JSON validation constraint
20+
/// </summary>
21+
public class MySqlJsonColumnConvention : IComplexPropertyAddedConvention, IComplexPropertyAnnotationChangedConvention, IComplexTypeAnnotationChangedConvention
22+
{
23+
/// <summary>
24+
/// Creates a new instance of <see cref="MySqlJsonColumnConvention" />.
25+
/// </summary>
26+
/// <param name="dependencies"> Parameter object containing dependencies for this convention. </param>
27+
/// <param name="relationalDependencies"> Parameter object containing relational dependencies for this convention. </param>
28+
public MySqlJsonColumnConvention(
29+
ProviderConventionSetBuilderDependencies dependencies,
30+
RelationalConventionSetBuilderDependencies relationalDependencies)
31+
{
32+
Dependencies = dependencies;
33+
RelationalDependencies = relationalDependencies;
34+
}
35+
36+
/// <summary>
37+
/// Dependencies for this service.
38+
/// </summary>
39+
protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; }
40+
41+
/// <summary>
42+
/// Relational provider-specific dependencies for this service.
43+
/// </summary>
44+
protected virtual RelationalConventionSetBuilderDependencies RelationalDependencies { get; }
45+
46+
/// <inheritdoc />
47+
public virtual void ProcessComplexPropertyAdded(
48+
IConventionComplexPropertyBuilder propertyBuilder,
49+
IConventionContext<IConventionComplexPropertyBuilder> context)
50+
{
51+
SetJsonColumnTypeIfNeeded(propertyBuilder);
52+
}
53+
54+
/// <inheritdoc />
55+
public virtual void ProcessComplexPropertyAnnotationChanged(
56+
IConventionComplexPropertyBuilder propertyBuilder,
57+
string name,
58+
IConventionAnnotation annotation,
59+
IConventionAnnotation oldAnnotation,
60+
IConventionContext<IConventionAnnotation> context)
61+
{
62+
// React when ContainerColumnName annotation is set (when .ToJson() is called)
63+
// ToJson() sets the container column name, not the JSON property name
64+
if (name == RelationalAnnotationNames.ContainerColumnName)
65+
{
66+
SetJsonColumnTypeIfNeeded(propertyBuilder);
67+
}
68+
}
69+
70+
/// <inheritdoc />
71+
public virtual void ProcessComplexTypeAnnotationChanged(
72+
IConventionComplexTypeBuilder complexTypeBuilder,
73+
string name,
74+
IConventionAnnotation annotation,
75+
IConventionAnnotation oldAnnotation,
76+
IConventionContext<IConventionAnnotation> context)
77+
{
78+
// React when ContainerColumnName annotation is set on the ComplexType
79+
if (name == RelationalAnnotationNames.ContainerColumnName)
80+
{
81+
var containerColumnName = annotation?.Value as string;
82+
83+
if (!string.IsNullOrEmpty(containerColumnName))
84+
{
85+
complexTypeBuilder.Metadata.SetContainerColumnType("json", fromDataAnnotation: false);
86+
}
87+
}
88+
}
89+
90+
private static void SetJsonColumnTypeIfNeeded(IConventionComplexPropertyBuilder propertyBuilder)
91+
{
92+
var complexProperty = propertyBuilder.Metadata;
93+
94+
// Check if this complex property is mapped to a JSON column
95+
// GetContainerColumnName() returns non-null/non-empty when .ToJson() is called
96+
// ToJson() sets it to jsonColumnName ?? complexProperty.Name, so it should never be null/empty
97+
var containerColumnName = complexProperty.ComplexType.GetContainerColumnName();
98+
99+
// If container column name is set (even to empty string), it means this is mapped to JSON
100+
// We check for not null here - empty string would be unusual but should still be handled
101+
if (containerColumnName != null && containerColumnName.Length > 0)
102+
{
103+
// Set the container column type to "json" for MySQL/MariaDB
104+
// Both databases accept "json" as the column type:
105+
// - MySQL 5.7.8+: Native JSON type with binary storage
106+
// - MariaDB 10.2.4+: JSON as alias for LONGTEXT with validation constraint
107+
108+
// For both single complex properties and complex collections,
109+
// we need to set the container column type on the ComplexType
110+
var complexType = complexProperty.ComplexType;
111+
if (complexType is IConventionComplexType conventionComplexType)
112+
{
113+
conventionComplexType.SetContainerColumnType("json", fromDataAnnotation: false);
114+
}
115+
}
116+
}
117+
}
118+
}

src/EFCore.MySql/Metadata/Internal/MySqlAnnotationProvider.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,31 @@ public override IEnumerable<IAnnotation> For(IColumn column, bool designTime)
164164
var table = StoreObjectIdentifier.Table(column.Table.Name, column.Table.Schema);
165165
var properties = column.PropertyMappings.Select(m => m.Property).ToArray();
166166

167+
// Check if this is a container column for a JSON-mapped complex property
168+
// Container columns don't have property mappings in the traditional sense
169+
if (column.PropertyMappings.Count == 0 && column.Name != null)
170+
{
171+
// This might be a container column for a complex property
172+
// Check if there's a complex property with this name that has JSON mapping
173+
var entityTypes = column.Table.EntityTypeMappings.Select(m => m.TypeBase as IEntityType).Where(e => e != null);
174+
foreach (var entityType in entityTypes)
175+
{
176+
foreach (var complexProperty in entityType.GetComplexProperties())
177+
{
178+
// Cast to IReadOnlyTypeBase to access GetContainerColumnName
179+
var containerColumnName = (complexProperty.ComplexType as IReadOnlyTypeBase)?.GetContainerColumnName();
180+
if (containerColumnName == column.Name && complexProperty.GetJsonPropertyName() != null)
181+
{
182+
// This is a JSON container column - set the column type to "json"
183+
yield return new Annotation(
184+
RelationalAnnotationNames.ColumnType,
185+
"json");
186+
yield break;
187+
}
188+
}
189+
}
190+
}
191+
167192
if (column.PropertyMappings.Where(
168193
m => (m.TableMapping.IsSharedTablePrincipal ?? true) &&
169194
m.TableMapping.TypeBase == m.Property.DeclaringType)

src/EFCore.MySql/Storage/Internal/MySqlJsonTypeMapping.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
using System;
55
using System.Collections.Generic;
66
using System.Data.Common;
7+
using System.IO;
78
using System.Linq;
9+
using System.Linq.Expressions;
10+
using System.Reflection;
11+
using System.Text;
812
using JetBrains.Annotations;
913
using Microsoft.EntityFrameworkCore;
1014
using Microsoft.EntityFrameworkCore.ChangeTracking;
@@ -57,6 +61,9 @@ protected override RelationalTypeMapping Clone(bool? noBackslashEscapes = null,
5761

5862
public abstract class MySqlJsonTypeMapping : MySqlStringTypeMapping, IMySqlCSharpRuntimeAnnotationTypeMappingCodeGenerator
5963
{
64+
protected static readonly MethodInfo _getString
65+
= typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetString), new[] { typeof(int) });
66+
6067
public MySqlJsonTypeMapping(
6168
[NotNull] string storeType,
6269
[NotNull] Type clrType,
@@ -118,6 +125,28 @@ protected override void ConfigureParameter(DbParameter parameter)
118125
}
119126
}
120127

128+
/// <summary>
129+
/// Returns the method to be used for reading JSON values from the database.
130+
/// MySQL stores JSON as strings, so we use GetString instead of the default GetFieldValue&lt;T&gt;.
131+
/// </summary>
132+
public override MethodInfo GetDataReaderMethod()
133+
{
134+
Console.WriteLine($"[DEBUG] MySqlJsonTypeMapping.GetDataReaderMethod() called - ClrType: {ClrType.Name} - returning DbDataReader.GetString");
135+
return _getString;
136+
}
137+
138+
/// <summary>
139+
/// Customizes the data reader expression for JSON types.
140+
/// This is only used for regular JSON columns mapped to string properties.
141+
/// Complex JSON types use MySqlStructuralJsonTypeMapping instead.
142+
/// </summary>
143+
public override Expression CustomizeDataReaderExpression(Expression expression)
144+
{
145+
Console.WriteLine($"[DEBUG] MySqlJsonTypeMapping.CustomizeDataReaderExpression() called - ClrType: {ClrType.Name} - no conversion");
146+
// For regular JSON columns, no conversion needed - just return the string
147+
return base.CustomizeDataReaderExpression(expression);
148+
}
149+
121150
void IMySqlCSharpRuntimeAnnotationTypeMappingCodeGenerator.Create(
122151
CSharpRuntimeAnnotationCodeGeneratorParameters codeGeneratorParameters,
123152
CSharpRuntimeAnnotationCodeGeneratorDependencies codeGeneratorDependencies)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) Pomelo Foundation. All rights reserved.
2+
// Licensed under the MIT. See LICENSE in the project root for license information.
3+
4+
using System;
5+
using System.Data.Common;
6+
using System.IO;
7+
using System.Linq.Expressions;
8+
using System.Reflection;
9+
using System.Text;
10+
using Microsoft.EntityFrameworkCore.Storage;
11+
using MySqlConnector;
12+
13+
namespace Pomelo.EntityFrameworkCore.MySql.Storage.Internal
14+
{
15+
/// <summary>
16+
/// Type mapping for complex JSON types (types mapped with .ToJson()).
17+
/// This mapping handles the conversion from MySQL's string-based JSON to MemoryStream expected by EF Core.
18+
/// Similar to Npgsql's NpgsqlStructuralJsonTypeMapping.
19+
/// </summary>
20+
public class MySqlStructuralJsonTypeMapping : JsonTypeMapping
21+
{
22+
private static readonly MethodInfo _getStringMethod
23+
= typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetString), new[] { typeof(int) });
24+
25+
private static readonly PropertyInfo _utf8Property
26+
= typeof(Encoding).GetProperty(nameof(Encoding.UTF8));
27+
28+
private static readonly MethodInfo _getBytesMethod
29+
= typeof(Encoding).GetMethod(nameof(Encoding.GetBytes), new[] { typeof(string) });
30+
31+
private static readonly ConstructorInfo _memoryStreamConstructor
32+
= typeof(MemoryStream).GetConstructor(new[] { typeof(byte[]) });
33+
34+
public static MySqlStructuralJsonTypeMapping Default { get; } = new("json");
35+
36+
public MySqlStructuralJsonTypeMapping(string storeType)
37+
: base(storeType, typeof(JsonTypePlaceholder), dbType: null)
38+
{
39+
Console.WriteLine($"[DEBUG] MySqlStructuralJsonTypeMapping created - StoreType: {storeType}, ClrType: JsonTypePlaceholder, DbType: null");
40+
}
41+
42+
protected MySqlStructuralJsonTypeMapping(RelationalTypeMappingParameters parameters)
43+
: base(parameters)
44+
{
45+
Console.WriteLine($"[DEBUG] MySqlStructuralJsonTypeMapping cloned - StoreType: {parameters.StoreType}");
46+
}
47+
48+
/// <summary>
49+
/// MySQL stores JSON as strings, so we read using GetString.
50+
/// </summary>
51+
public override MethodInfo GetDataReaderMethod()
52+
{
53+
Console.WriteLine("[DEBUG] MySqlStructuralJsonTypeMapping.GetDataReaderMethod() called - returning DbDataReader.GetString");
54+
return _getStringMethod;
55+
}
56+
57+
/// <summary>
58+
/// Customizes the data reader expression to convert string to MemoryStream.
59+
/// Creates: new MemoryStream(Encoding.UTF8.GetBytes(stringValue))
60+
/// </summary>
61+
public override Expression CustomizeDataReaderExpression(Expression expression)
62+
{
63+
Console.WriteLine("[DEBUG] MySqlStructuralJsonTypeMapping.CustomizeDataReaderExpression() called - converting string to MemoryStream");
64+
return Expression.New(
65+
_memoryStreamConstructor,
66+
Expression.Call(
67+
Expression.Property(null, _utf8Property),
68+
_getBytesMethod,
69+
expression));
70+
}
71+
72+
protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters)
73+
=> new MySqlStructuralJsonTypeMapping(parameters);
74+
75+
/// <summary>
76+
/// Override to ensure we return JsonTypePlaceholder type consistently
77+
/// </summary>
78+
public override Type ClrType => typeof(JsonTypePlaceholder);
79+
80+
protected string EscapeSqlLiteral(string literal)
81+
=> literal.Replace("'", "''");
82+
83+
protected override string GenerateNonNullSqlLiteral(object value)
84+
=> $"'{EscapeSqlLiteral((string)value)}'";
85+
86+
protected override void ConfigureParameter(DbParameter parameter)
87+
{
88+
// MySQL uses JSON db type for JSON columns
89+
if (parameter is MySqlParameter mySqlParameter)
90+
{
91+
mySqlParameter.MySqlDbType = MySqlDbType.JSON;
92+
}
93+
94+
base.ConfigureParameter(parameter);
95+
}
96+
}
97+
}

src/EFCore.MySql/Storage/Internal/MySqlTypeMappingSource.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ private readonly RelationalTypeMapping _binaryRowVersion6
8787
// guid
8888
private GuidTypeMapping _guid;
8989

90-
// JSON default mapping
90+
// JSON default mapping for regular JSON columns mapped to string
9191
private MySqlJsonTypeMapping<string> _jsonDefaultString;
92+
// JSON mapping for complex types (types mapped with .ToJson())
93+
private MySqlStructuralJsonTypeMapping _jsonStructural;
9294

9395
// Scaffolding type mappings
9496
private readonly MySqlCodeGenerationMemberAccessTypeMapping _codeGenerationMemberAccess = MySqlCodeGenerationMemberAccessTypeMapping.Default;
@@ -135,6 +137,7 @@ private void Initialize()
135137
: null;
136138

137139
_jsonDefaultString = new MySqlJsonTypeMapping<string>("json", null, null, _options.NoBackslashEscapes, _options.ReplaceLineBreaksWithCharFunction);
140+
_jsonStructural = new MySqlStructuralJsonTypeMapping("json");
138141

139142
_storeTypeMappings
140143
= new Dictionary<string, RelationalTypeMapping[]>(StringComparer.OrdinalIgnoreCase)
@@ -201,6 +204,10 @@ private void Initialize()
201204
{ "time", new RelationalTypeMapping[] { _timeTimeOnly, _timeTimeSpan } },
202205
{ "datetime", new RelationalTypeMapping[] { _dateTime, _dateTimeOffset } },
203206
{ "timestamp", new RelationalTypeMapping[] { _timeStamp, _timeStampOffset } },
207+
208+
// json - for complex types mapped with .ToJson()
209+
// This supports both MySQL 5.7.8+ (native JSON) and MariaDB 10.2.4+ (JSON as LONGTEXT alias)
210+
{ "json", new[] { _jsonDefaultString } },
204211
};
205212

206213
_clrTypeMappings
@@ -305,6 +312,15 @@ private RelationalTypeMapping FindRawMapping(RelationalTypeMappingInfo mappingIn
305312
var storeTypeName = mappingInfo.StoreTypeName;
306313
var storeTypeNameBase = mappingInfo.StoreTypeNameBase;
307314

315+
// Special case for JSON columns: EF Core passes JsonTypePlaceholder as the CLR type
316+
// when creating JSON columns for complex types/collections. Return our structural JSON mapping.
317+
// This MUST be checked first, before any store type lookups, similar to SQL Server's implementation.
318+
if (clrType?.Name == "JsonTypePlaceholder")
319+
{
320+
Console.WriteLine($"[DEBUG] MySqlTypeMappingSource: Detected JsonTypePlaceholder - returning MySqlStructuralJsonTypeMapping");
321+
return _jsonStructural;
322+
}
323+
308324
if (storeTypeName != null)
309325
{
310326
// First look for the fully qualified store type name.
@@ -315,6 +331,7 @@ private RelationalTypeMapping FindRawMapping(RelationalTypeMappingInfo mappingIn
315331
// mapping as the default.
316332
// If a CLR type was provided, look for a mapping between the store and CLR types. If none is found,
317333
// fail immediately.
334+
318335
return clrType == null
319336
? mappings[0]
320337
: mappings.FirstOrDefault(m => m.ClrType == clrType);
@@ -330,9 +347,15 @@ private RelationalTypeMapping FindRawMapping(RelationalTypeMappingInfo mappingIn
330347
?.WithTypeMappingInfo(in mappingInfo);
331348
}
332349

333-
if (storeTypeName.Equals("json", StringComparison.OrdinalIgnoreCase) &&
334-
(clrType == null || clrType == typeof(string) || clrType == typeof(MySqlJsonString)))
350+
// Handle JSON store type for any CLR type
351+
// This is needed for complex collections mapped with .ToJson() in EF Core 10+
352+
// Works for both MySQL (native JSON type) and MariaDB (JSON alias for LONGTEXT)
353+
if (storeTypeName.Equals("json", StringComparison.OrdinalIgnoreCase))
335354
{
355+
// Return JSON mapping for any CLR type since JSON can serialize any object
356+
// The "json" store type works for both:
357+
// - MySQL 5.7.8+: Creates native JSON column with binary storage
358+
// - MariaDB 10.2.4+: Creates LONGTEXT column with JSON validation constraint
336359
return _jsonDefaultString;
337360
}
338361

0 commit comments

Comments
 (0)