diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs index f28f1d15523..76b97818d0b 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; +using System.Data.SqlTypes; using System.Text; +using System.Xml; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore.Storage.Json; @@ -19,6 +21,9 @@ public class SqlServerStringTypeMapping : StringTypeMapping private const int UnicodeMax = 4000; private const int AnsiMax = 8000; + private const string Utf8XmlDeclaration = ""; + private const string Utf16XmlDeclaration = ""; + private static readonly CaseInsensitiveValueComparer CaseInsensitiveValueComparer = new(); private readonly bool _isUtf16; @@ -110,8 +115,9 @@ protected SqlServerStringTypeMapping(RelationalTypeMappingParameters parameters, _maxSize = AnsiMax; } - _isUtf16 = parameters.Unicode && parameters.StoreType.StartsWith("n", StringComparison.OrdinalIgnoreCase); _sqlDbType = sqlDbType; + _isUtf16 = _sqlDbType == SqlDbType.Xml + || (parameters.Unicode && parameters.StoreType.StartsWith("n", StringComparison.OrdinalIgnoreCase)); } /// @@ -147,6 +153,14 @@ protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters p protected override void ConfigureParameter(DbParameter parameter) { var value = parameter.Value; + + if (_sqlDbType == SqlDbType.Xml + && value is string stringValue + && stringValue.StartsWith(Utf8XmlDeclaration, StringComparison.Ordinal)) + { + value = parameter.Value = string.Concat(Utf16XmlDeclaration, stringValue.AsSpan(Utf8XmlDeclaration.Length)); + } + var length = (value as string)?.Length; if (_sqlDbType.HasValue @@ -211,7 +225,17 @@ protected override string GenerateNonNullSqlLiteral(object value) var concatCount = 1; var concatStartList = new List(); var insideConcat = false; - for (i = 0; i < stringValue.Length; i++) + + if (_sqlDbType == SqlDbType.Xml + && stringValue.StartsWith(Utf8XmlDeclaration, StringComparison.Ordinal)) + { + // The value is sent to the server as 'xml', so a UTF-8 prolog is rewritten to UTF-16. + builder.Append('N').Append('\'').Append(Utf16XmlDeclaration); + openApostrophe = true; + start = Utf8XmlDeclaration.Length; + } + + for (i = start; i < stringValue.Length; i++) { var lineFeed = stringValue[i] == '\n'; var carriageReturn = stringValue[i] == '\r'; diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs index 2523a347c8f..b0fc6184acc 100644 --- a/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerTypeMappingSource.cs @@ -120,7 +120,7 @@ private static readonly GuidTypeMapping Uniqueidentifier = new("uniqueidentifier"); private static readonly SqlServerStringTypeMapping Xml - = new("xml", unicode: true, storeTypePostfix: StoreTypePostfix.None); + = new("xml", unicode: true, sqlDbType: SqlDbType.Xml, storeTypePostfix: StoreTypePostfix.None); private static readonly Dictionary _clrTypeMappings; diff --git a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs index 1ac0dc92916..b5db58c6c4d 100644 --- a/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs @@ -3816,6 +3816,86 @@ FROM INFORMATION_SCHEMA.COLUMNS return actual; } + // The grinning-face emoji is outside the BMP (a UTF-16 surrogate pair, four UTF-8 bytes) and the euro sign + // is a single UTF-16 code unit but three UTF-8 bytes; both are represented differently in UTF-16 than in + // UTF-8 and are lost when an xml value is sent to the server as a non-Unicode string, which makes them good + // probes for the SqlXml/SqlDbType.Xml parameter path. + private const string XmlEmoji = "\U0001F600"; + private const string XmlEuro = "\u20AC"; + + [Theory] + [InlineData("" + XmlEmoji + XmlEuro + "", "" + XmlEmoji + XmlEuro + "")] + // An explicit non-UTF-16 prolog is accepted because the value is sent as 'xml', not 'nvarchar(max)'. + [InlineData("" + XmlEmoji + "", "" + XmlEmoji + "")] + [InlineData("" + XmlEuro + "", "" + XmlEuro + "")] + // Content forms that the 'xml' store type accepts beyond a single well-formed document. + [InlineData("", "")] + [InlineData("text fragment", "text fragment")] + [InlineData("", "")] + public async Task Xml_value_round_trips(string value, string expected) + { + await using var context = CreateContext(); + + var document = new XmlTestDocument { Content = value }; + context.Add(document); + await context.SaveChangesAsync(); + + var id = document.Id; + context.ChangeTracker.Clear(); + + // xml columns cannot be compared directly in a WHERE clause, so the row is fetched by its key. Coalescing + // the column with the original value sends that value as an 'xml' parameter, exercising the SqlXml + // parameter path in a query in addition to the insert above. + var query = context.Set() + .Where(d => d.Id == id) + .Select(d => d.Content ?? value); + + // A UTF-8 prolog is rewritten to UTF-16 because the value is sent as an 'xml' parameter. + var expectedParameterValue = value.StartsWith("", StringComparison.Ordinal) + ? "" + value["".Length..] + : value; + + Assert.Equal( + $""" +DECLARE @value xml = N'{expectedParameterValue}'; +DECLARE @id int = {id}; + +SELECT COALESCE([x].[Content], @value) +FROM [XmlTestDocument] AS [x] +WHERE [x].[Id] = @id +""", + query.ToQueryString()); + + var roundTripped = await query.SingleAsync(); + Assert.Equal(expected, roundTripped); + + AssertSql( + $""" +@p0='{expectedParameterValue}' (Size = 4000) (DbType = Xml) + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [XmlTestDocument] ([Content]) +OUTPUT INSERTED.[Id] +VALUES (@p0); +""", + // + $""" +@value='{expectedParameterValue}' (Size = 4000) (DbType = Xml) +@id='{id}' + +SELECT TOP(2) COALESCE([x].[Content], @value) +FROM [XmlTestDocument] AS [x] +WHERE [x].[Id] = @id +"""); + } + + private class XmlTestDocument + { + public int Id { get; set; } + public string Content { get; set; } + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); @@ -3897,6 +3977,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con b.Property(e => e.DecimalAsDec52).HasPrecision(7, 3); }); + modelBuilder.Entity().Property(e => e.Content).HasColumnType("xml"); + MakeRequired(modelBuilder); MakeRequired(modelBuilder); MakeRequired(modelBuilder); diff --git a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs index 1657cda4b61..cee76515234 100644 --- a/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs +++ b/test/EFCore.SqlServer.Tests/Storage/SqlServerTypeMappingTest.cs @@ -378,6 +378,22 @@ public virtual void Char_Utf8() Assert.Equal(DbType.String, parameter.DbType); } + [Fact] + public virtual void Xml_null_parameter_is_sent_as_SqlDbType_Xml() + { + var mapping = GetMapping("xml"); + + using var command = CreateTestCommand(); + var parameter = (SqlParameter)mapping.CreateParameter(command, "foo", null, nullable: true); + + Assert.Equal(SqlDbType.Xml, parameter.SqlDbType); + Assert.Equal(DBNull.Value, parameter.Value); + } + + [Fact] + public virtual void Xml_literal_is_generated_as_unicode() + => Test_GenerateSqlLiteral_helper(GetMapping("xml"), "\U0001F62D", "N'\U0001F62D'"); + [Fact] public virtual void DateOnly_code_literal_generated_correctly() {