diff --git a/src/EFCore.SqlServer.NTS/Storage/ValueConversion/Internal/GeometryValueConverter.cs b/src/EFCore.SqlServer.NTS/Storage/ValueConversion/Internal/GeometryValueConverter.cs index ad187349e99..76d6f415b8b 100644 --- a/src/EFCore.SqlServer.NTS/Storage/ValueConversion/Internal/GeometryValueConverter.cs +++ b/src/EFCore.SqlServer.NTS/Storage/ValueConversion/Internal/GeometryValueConverter.cs @@ -16,6 +16,14 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Storage.ValueConversion.Intern public class GeometryValueConverter : ValueConverter where TGeometry : Geometry { + // The IsValid flag is at bit 2 (0x04) of the Properties byte at offset 5 in the SQL Server + // geography/geometry binary format (MS-SSCLRT). SqlServerBytesWriter sets this flag based on + // NTS's Geometry.IsValid, but NTS and SQL Server use different validation rules. We always set + // this flag to true to avoid NTS validation rules incorrectly marking geometries as invalid + // in SQL Server. See https://github.com/dotnet/efcore/issues/37416 + private const int PropertiesByteIndex = 5; + private const byte IsValidFlag = 0x04; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -24,8 +32,18 @@ public class GeometryValueConverter : ValueConverter public GeometryValueConverter(SqlServerBytesReader reader, SqlServerBytesWriter writer) : base( - g => new SqlBytes(writer.Write(g)), + g => new SqlBytes(SetIsValidFlag(writer.Write(g))), b => (TGeometry)reader.Read(b.Value)) { } + + private static byte[] SetIsValidFlag(byte[] bytes) + { + if (bytes.Length > PropertiesByteIndex) + { + bytes[PropertiesByteIndex] |= IsValidFlag; + } + + return bytes; + } } diff --git a/test/EFCore.SqlServer.Tests/Storage/ValueConversion/Internal/GeometryValueConverterTest.cs b/test/EFCore.SqlServer.Tests/Storage/ValueConversion/Internal/GeometryValueConverterTest.cs new file mode 100644 index 00000000000..0332bd2a5ed --- /dev/null +++ b/test/EFCore.SqlServer.Tests/Storage/ValueConversion/Internal/GeometryValueConverterTest.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.SqlTypes; +using NetTopologySuite; +using NetTopologySuite.Geometries; +using NetTopologySuite.IO; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Storage.ValueConversion.Internal; + +public class GeometryValueConverterTest +{ + // The IsValid flag is at bit 2 (0x04) of the Properties byte at offset 5 in the SQL Server + // geography/geometry binary format (MS-SSCLRT). + private const int PropertiesByteIndex = 5; + private const byte IsValidFlag = 0x04; + + [ConditionalFact] + public void IsValid_flag_set_for_valid_geometry() + { + var point = new Point(1, 2) { SRID = 4326 }; + Assert.True(point.IsValid); + + var converter = CreateConverter(isGeography: false); + var sqlBytes = (SqlBytes)converter.ConvertToProvider(point)!; + + Assert.True((sqlBytes.Value[PropertiesByteIndex] & IsValidFlag) != 0, "IsValid flag should be set for valid geometry"); + } + + [ConditionalFact] + public void IsValid_flag_set_for_valid_geography() + { + var point = new Point(1, 2) { SRID = 4326 }; + Assert.True(point.IsValid); + + var converter = CreateConverter(isGeography: true); + var sqlBytes = (SqlBytes)converter.ConvertToProvider(point)!; + + Assert.True((sqlBytes.Value[PropertiesByteIndex] & IsValidFlag) != 0, "IsValid flag should be set for valid geography"); + } + + [ConditionalFact] + public void IsValid_flag_set_for_invalid_geometry() + { + // Create an invalid geometry (self-intersecting polygon - bowtie shape) + var polygon = new Polygon( + new LinearRing( + [ + new Coordinate(0, 0), + new Coordinate(2, 2), + new Coordinate(2, 0), + new Coordinate(0, 2), + new Coordinate(0, 0) + ])); + Assert.False(polygon.IsValid); + + var converter = CreateConverter(isGeography: false); + var sqlBytes = (SqlBytes)converter.ConvertToProvider(polygon)!; + + Assert.True( + (sqlBytes.Value[PropertiesByteIndex] & IsValidFlag) != 0, + "IsValid flag should be set even for NTS-invalid geometry"); + } + + [ConditionalFact] + public void IsValid_flag_set_for_invalid_geography() + { + // Create an invalid geography (self-intersecting polygon - bowtie shape) + var polygon = new Polygon( + new LinearRing( + [ + new Coordinate(0, 0), + new Coordinate(2, 2), + new Coordinate(2, 0), + new Coordinate(0, 2), + new Coordinate(0, 0) + ])) { SRID = 4326 }; + Assert.False(polygon.IsValid); + + var converter = CreateConverter(isGeography: true); + var sqlBytes = (SqlBytes)converter.ConvertToProvider(polygon)!; + + Assert.True( + (sqlBytes.Value[PropertiesByteIndex] & IsValidFlag) != 0, + "IsValid flag should be set even for NTS-invalid geography"); + } + + [ConditionalFact] + public void Roundtrip_preserves_geometry_data() + { + var point = new Point(1, 2) { SRID = 4326 }; + + var converter = CreateConverter(isGeography: false); + var sqlBytes = (SqlBytes)converter.ConvertToProvider(point)!; + var roundtripped = (Point)converter.ConvertFromProvider(sqlBytes)!; + + Assert.Equal(point.X, roundtripped.X); + Assert.Equal(point.Y, roundtripped.Y); + Assert.Equal(point.SRID, roundtripped.SRID); + } + + private static GeometryValueConverter CreateConverter(bool isGeography) + { + var reader = new SqlServerBytesReader(NtsGeometryServices.Instance) { IsGeography = isGeography }; + var writer = new SqlServerBytesWriter { IsGeography = isGeography }; + return new GeometryValueConverter(reader, writer); + } +}