diff --git a/AspNetCoreOData.NetTopologySuite.sln b/AspNetCoreOData.NetTopologySuite.sln new file mode 100644 index 000000000..fb906eb27 --- /dev/null +++ b/AspNetCoreOData.NetTopologySuite.sln @@ -0,0 +1,71 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OData", "src\Microsoft.AspNetCore.OData\Microsoft.AspNetCore.OData.csproj", "{79D9A62B-36EB-4345-AFCE-081397C29F7E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OData.NetTopologySuite", "src\Microsoft.AspNetCore.OData.NetTopologySuite\Microsoft.AspNetCore.OData.NetTopologySuite.csproj", "{57ECAA5E-54AB-46AD-A7C6-981BF39E3620}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.OData.NetTopologySuite.Tests", "test\Microsoft.AspNetCore.OData.NetTopologySuite.Tests\Microsoft.AspNetCore.OData.NetTopologySuite.Tests.csproj", "{5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Debug|x64.Build.0 = Debug|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Debug|x86.Build.0 = Debug|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Release|Any CPU.Build.0 = Release|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Release|x64.ActiveCfg = Release|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Release|x64.Build.0 = Release|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Release|x86.ActiveCfg = Release|Any CPU + {79D9A62B-36EB-4345-AFCE-081397C29F7E}.Release|x86.Build.0 = Release|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Debug|x64.ActiveCfg = Debug|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Debug|x64.Build.0 = Debug|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Debug|x86.ActiveCfg = Debug|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Debug|x86.Build.0 = Debug|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Release|Any CPU.Build.0 = Release|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Release|x64.ActiveCfg = Release|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Release|x64.Build.0 = Release|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Release|x86.ActiveCfg = Release|Any CPU + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620}.Release|x86.Build.0 = Release|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Debug|x64.ActiveCfg = Debug|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Debug|x64.Build.0 = Debug|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Debug|x86.Build.0 = Debug|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Release|Any CPU.Build.0 = Release|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Release|x64.ActiveCfg = Release|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Release|x64.Build.0 = Release|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Release|x86.ActiveCfg = Release|Any CPU + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {79D9A62B-36EB-4345-AFCE-081397C29F7E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {57ECAA5E-54AB-46AD-A7C6-981BF39E3620} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {5E8F75EF-B325-4564-8EAF-A39BAC8FE4D8} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + EndGlobalSection +EndGlobal diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite.Nightly.nuspec b/src/Microsoft.AspNetCore.OData.NetTopologySuite.Nightly.nuspec new file mode 100644 index 000000000..dfe6fdea1 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite.Nightly.nuspec @@ -0,0 +1,38 @@ + + + + Microsoft.AspNetCore.OData.NetTopologySuite + ASP.NET Core OData NetTopologySuite Integration + $VersionFullSemantic$-Nightly$NightlyBuildVersion$ + Microsoft + © Microsoft Corporation. All rights reserved. + Adds NetTopologySuite (NTS) spatial support to ASP.NET Core OData. Enables using NTS geometry types (Point, LineString, Polygon, Multi*, GeometryCollection) as Edm.Geometry, and opt-in Edm.Geography mapping via the [Geography] attribute and conventions. Includes an EDM type-mapping provider and spatial serializers/deserializers. + NetTopologySuite spatial support for ASP.NET Core OData. + en-US + http://github.com/OData/AspNetCoreOData + MIT + true + Microsoft AspNetCore WebApi OData + docs\README.md + images\odata.png + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite.Release.nuspec b/src/Microsoft.AspNetCore.OData.NetTopologySuite.Release.nuspec new file mode 100644 index 000000000..10ffc3fac --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite.Release.nuspec @@ -0,0 +1,38 @@ + + + + Microsoft.AspNetCore.OData.NetTopologySuite + ASP.NET Core OData NetTopologySuite Integration + $VersionNuGetSemantic$ + Microsoft + © Microsoft Corporation. All rights reserved. + Adds NetTopologySuite (NTS) spatial support to ASP.NET Core OData. Enables using NTS geometry types (Point, LineString, Polygon, Multi*, GeometryCollection) as Edm.Geometry, and opt-in Edm.Geography mapping via the [Geography] attribute and conventions. Includes an EDM type-mapping provider and spatial serializers/deserializers. + NetTopologySuite spatial support for ASP.NET Core OData. + en-US + http://github.com/OData/AspNetCoreOData + MIT + true + Microsoft AspNetCore OData NetTopologySuite + docs\README.md + images\odata.png + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Attributes/GeographyAttribute.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Attributes/GeographyAttribute.cs new file mode 100644 index 000000000..e6da4a45b --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Attributes/GeographyAttribute.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Conventions; +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Attributes; + +/// +/// Marks a property or CLR type so that NetTopologySuite geometry values are modeled as Edm geography types. +/// +/// +/// - When applied to a property whose CLR type derives from , +/// the property is mapped to the corresponding in the Edm.Geography* family +/// (for example, Point → GeographyPoint). +/// - When applied to a class, all properties on the type whose CLR types derive from +/// are treated as Edm.Geography*. +/// The behavior is enforced by the conventions +/// and +/// . +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class, Inherited = true, AllowMultiple = false)] +public class GeographyAttribute : Attribute +{ +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Common/Error.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Common/Error.cs new file mode 100644 index 000000000..bcbeb4f1a --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Common/Error.cs @@ -0,0 +1,58 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Common; + +[ExcludeFromCodeCoverage] +internal static class Error +{ + /// + /// Formats the specified resource string using . + /// + /// A composite format string. + /// An object array that contains zero or more objects to format. + /// The formatted string. + internal static string Format(string format, params object[] args) + { + return String.Format(CultureInfo.CurrentCulture, format, args); + } + + /// + /// Creates an with the provided properties. + /// + /// The name of the parameter that caused the current exception. + /// The logged . + internal static ArgumentNullException ArgumentNull(string parameterName) + { + return new ArgumentNullException(parameterName); + } + + /// + /// Creates an . + /// + /// A composite format string explaining the reason for the exception. + /// An object array that contains zero or more objects to format. + /// The logged . + internal static InvalidOperationException InvalidOperation(string messageFormat, params object[] messageArgs) + { + return new InvalidOperationException(Error.Format(messageFormat, messageArgs)); + } + + /// + /// Creates an . + /// + /// A composite format string explaining the reason for the exception. + /// An object array that contains zero or more objects to format. + /// The logged . + internal static NotSupportedException NotSupported(string messageFormat, params object[] messageArgs) + { + return new NotSupportedException(Error.Format(messageFormat, messageArgs)); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Conventions/GeographyAttributeEdmPropertyConvention.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Conventions/GeographyAttributeEdmPropertyConvention.cs new file mode 100644 index 000000000..a7d245228 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Conventions/GeographyAttributeEdmPropertyConvention.cs @@ -0,0 +1,82 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Attributes; +using Microsoft.AspNetCore.OData.NetTopologySuite.Common; +using Microsoft.AspNetCore.OData.NetTopologySuite.Edm; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using Microsoft.OData.ModelBuilder.Conventions.Attributes; +using Geometry = NetTopologySuite.Geometries.Geometry; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Conventions; + +/// +/// A convention that applies to individual properties decorated with the . +/// If the property’s CLR type is a NetTopologySuite spatial type, it is mapped to the corresponding +/// OData geography Edm type (for example, PointEdm.GeographyPoint, +/// LineStringEdm.GeographyLineString). +/// +public class GeographyAttributeEdmPropertyConvention : AttributeEdmPropertyConvention +{ + /// + /// Initializes a new instance of the class. + /// Configures the convention to match properties annotated with and + /// to map NetTopologySuite spatial CLR types to their Edm.Geography* primitive kinds. + /// + public GeographyAttributeEdmPropertyConvention() + : base(attribute => attribute.GetType() == typeof(GeographyAttribute), allowMultiple: false) + { + + } + + /// + public override void Apply(PropertyConfiguration edmProperty, StructuralTypeConfiguration structuralTypeConfiguration, Attribute attribute, ODataConventionModelBuilder model) + { + if (edmProperty == null) + { + throw Error.ArgumentNull(nameof(edmProperty)); + } + + if (structuralTypeConfiguration == null) + { + throw Error.ArgumentNull(nameof(structuralTypeConfiguration)); + } + + if (model == null) + { + throw Error.ArgumentNull(nameof(model)); + } + + // If the property was added explicitly, we do not apply the convention. + if (edmProperty.AddedExplicitly) + { + return; + } + + if (edmProperty is PrimitivePropertyConfiguration primitiveProperty + && typeof(Geometry).IsAssignableFrom(edmProperty.RelatedClrType)) + { + EdmPrimitiveTypeKind? targetPrimitiveTypeKind = EdmSpatialKindMapper.GetGeographyKindForClrType(edmProperty.RelatedClrType); + if (targetPrimitiveTypeKind.HasValue) + { + primitiveProperty.AsSpatial(targetPrimitiveTypeKind.Value); + } + } + else if (edmProperty is CollectionPropertyConfiguration collectionProperty + && typeof(Geometry).IsAssignableFrom(collectionProperty.RelatedClrType)) + { + EdmPrimitiveTypeKind? elementGeographyKind = + EdmSpatialKindMapper.GetGeographyKindForClrType(collectionProperty.RelatedClrType); + + if (elementGeographyKind.HasValue) + { + collectionProperty.AsSpatial(elementGeographyKind.Value); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Conventions/GeographyAttributeEdmTypeConvention.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Conventions/GeographyAttributeEdmTypeConvention.cs new file mode 100644 index 000000000..8621060f8 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Conventions/GeographyAttributeEdmTypeConvention.cs @@ -0,0 +1,79 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Attributes; +using Microsoft.AspNetCore.OData.NetTopologySuite.Common; +using Microsoft.AspNetCore.OData.NetTopologySuite.Edm; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using Microsoft.OData.ModelBuilder.Conventions.Attributes; +using Geometry = NetTopologySuite.Geometries.Geometry; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Conventions; + +/// +/// A convention that applies to entity or complex types decorated with the . +/// When applied, all NetTopologySuite spatial properties on the type are mapped to the corresponding +/// OData geography Edm types (for example, PointEdm.GeographyPoint, +/// LineStringEdm.GeographyLineString), rather than geometry types. +/// +public class GeographyAttributeEdmTypeConvention : AttributeEdmTypeConvention +{ + /// + /// Initializes a new instance of the class. + /// Configures the convention to match types annotated with and + /// to map all NetTopologySuite spatial properties on those types to Edm.Geography* primitive kinds. + /// + public GeographyAttributeEdmTypeConvention() + : base(attribute => attribute.GetType() == typeof(GeographyAttribute), allowMultiple: false) + { + + } + + /// + public override void Apply(StructuralTypeConfiguration edmTypeConfiguration, ODataConventionModelBuilder model, Attribute attribute) + { + if (edmTypeConfiguration == null) + { + throw Error.ArgumentNull(nameof(edmTypeConfiguration)); + } + + if (model == null) + { + throw Error.ArgumentNull(nameof(model)); + } + + foreach (PropertyConfiguration edmProperty in edmTypeConfiguration.Properties) + { + if (edmProperty.AddedExplicitly) + { + continue; + } + + if (edmProperty is PrimitivePropertyConfiguration primitiveProperty + && typeof(Geometry).IsAssignableFrom(edmProperty.RelatedClrType)) + { + EdmPrimitiveTypeKind? targetPrimitiveTypeKind = EdmSpatialKindMapper.GetGeographyKindForClrType(edmProperty.RelatedClrType); + if (targetPrimitiveTypeKind.HasValue) + { + primitiveProperty.AsSpatial(targetPrimitiveTypeKind.Value); + } + } + else if (edmProperty is CollectionPropertyConfiguration collectionProperty + && typeof(Geometry).IsAssignableFrom(collectionProperty.RelatedClrType)) + { + EdmPrimitiveTypeKind? elementGeographyKind = + EdmSpatialKindMapper.GetGeographyKindForClrType(collectionProperty.RelatedClrType); + + if (elementGeographyKind.HasValue) + { + collectionProperty.AsSpatial(elementGeographyKind.Value); + } + } + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Edm/EdmSpatialKindMapper.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Edm/EdmSpatialKindMapper.cs new file mode 100644 index 000000000..294da43c4 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Edm/EdmSpatialKindMapper.cs @@ -0,0 +1,73 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Edm; + +/// +/// Maps NetTopologySuite geometry CLR types to their corresponding OData Edm geography primitive kinds. +/// +/// +/// The mapping prefers the most specific known NTS shape (Point, LineString, Polygon, Multi*, GeometryCollection) +/// and falls back to . +/// Returns null if the supplied type is null or not an NTS geometry type. +/// +internal static class EdmSpatialKindMapper +{ + /// + /// Gets the Edm geography primitive kind that corresponds to the given NetTopologySuite CLR type. + /// + /// The CLR type to evaluate (e.g., Point, LineString). + /// + /// The matching for geography shapes, or null if no mapping exists. + /// + public static EdmPrimitiveTypeKind? GetGeographyKindForClrType(Type relatedClrType) + { + if (relatedClrType == null) + { + return null; + } + + // Check most specific types first, then fall back to base Geometry. + if (typeof(Point).IsAssignableFrom(relatedClrType)) + { + return EdmPrimitiveTypeKind.GeographyPoint; + } + if (typeof(LineString).IsAssignableFrom(relatedClrType)) + { + return EdmPrimitiveTypeKind.GeographyLineString; + } + if (typeof(Polygon).IsAssignableFrom(relatedClrType)) + { + return EdmPrimitiveTypeKind.GeographyPolygon; + } + if (typeof(MultiPoint).IsAssignableFrom(relatedClrType)) + { + return EdmPrimitiveTypeKind.GeographyMultiPoint; + } + if (typeof(MultiLineString).IsAssignableFrom(relatedClrType)) + { + return EdmPrimitiveTypeKind.GeographyMultiLineString; + } + if (typeof(MultiPolygon).IsAssignableFrom(relatedClrType)) + { + return EdmPrimitiveTypeKind.GeographyMultiPolygon; + } + if (typeof(GeometryCollection).IsAssignableFrom(relatedClrType)) + { + return EdmPrimitiveTypeKind.GeographyCollection; + } + if (typeof(Geometry).IsAssignableFrom(relatedClrType)) + { + return EdmPrimitiveTypeKind.Geography; + } + + return null; + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Edm/ODataNetTopologySuiteTypeMapper.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Edm/ODataNetTopologySuiteTypeMapper.cs new file mode 100644 index 000000000..dba8c1074 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Edm/ODataNetTopologySuiteTypeMapper.cs @@ -0,0 +1,60 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using GeometryCollection = NetTopologySuite.Geometries.GeometryCollection; +using LineString = NetTopologySuite.Geometries.LineString; +using MultiLineString = NetTopologySuite.Geometries.MultiLineString; +using MultiPoint = NetTopologySuite.Geometries.MultiPoint; +using MultiPolygon = NetTopologySuite.Geometries.MultiPolygon; +using Point = NetTopologySuite.Geometries.Point; +using Polygon = NetTopologySuite.Geometries.Polygon; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Edm; + +/// +/// A type mapper that registers mappings between NetTopologySuite (NTS) geometry CLR types +/// and OData Edm spatial primitive kinds. +/// +/// +/// - Maps NTS types to Edm.Geometry* kinds (e.g., ). +/// - This mapper complements the default mapper by adding non-standard mappings for NTS reference types. +/// - This mapper is attached to an as a direct-value annotation to scope it per model/route. +/// +public class ODataNetTopologySuiteTypeMapper : DefaultODataTypeMapper +{ + /// + /// Singleton instance of . + /// + internal static readonly ODataNetTopologySuiteTypeMapper Instance; + + static ODataNetTopologySuiteTypeMapper() + { + Instance = new ODataNetTopologySuiteTypeMapper(); + } + + /// + /// Registers the NetTopologySuite geometry CLR types as Edm spatial primitive kinds. + /// + /// + /// Mappings are registered as non-standard to avoid overriding the default primitive mappings and + /// to allow providers/conventions to influence the final Edm kind (e.g., switch to geography). + /// + protected override void RegisterSpatialMappings() + { + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryPoint, isStandard: false); + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryLineString, isStandard: false); + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryPolygon, isStandard: false); + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryMultiPoint, isStandard: false); + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryMultiLineString, isStandard: false); + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryMultiPolygon, isStandard: false); + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryCollection, isStandard: false); + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.Geometry, isStandard: false); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologyServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologyServiceCollectionExtensions.cs new file mode 100644 index 000000000..0be69fbf1 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologyServiceCollectionExtensions.cs @@ -0,0 +1,56 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Formatter.Deserialization; +using Microsoft.AspNetCore.OData.Formatter.Serialization; +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Deserialization; +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization; +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; + +/// +/// Extension methods to register NetTopologySuite spatial formatters for ASP.NET Core OData. +/// +/// +/// This replaces the default OData spatial serializer/deserializer with implementations that read and write +/// NetTopologySuite geometry types. +/// +public static class ODataNetTopologyServiceCollectionExtensions +{ + /// + /// Registers NetTopologySuite spatial serializers and deserializers. + /// + /// The service collection. + /// The same for chaining. + /// + /// This method is idempotent: it removes existing spatial formatter registrations and adds the NTS-based ones. + /// + public static IServiceCollection AddODataNetTopologySuite(this IServiceCollection services) + { + services.RemoveAll(); + services.AddSingleton(); + services.RemoveAll(); + services.AddSingleton(); + + services.TryAddSingleton(); + services.TryAddEnumerable(new[] + { + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + }); + + return services; + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologySuiteEdmModelExtensions.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologySuiteEdmModelExtensions.cs new file mode 100644 index 000000000..79e43521d --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologySuiteEdmModelExtensions.cs @@ -0,0 +1,38 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.AspNetCore.OData.NetTopologySuite.Common; +using Microsoft.AspNetCore.OData.NetTopologySuite.Edm; +using Microsoft.AspNetCore.OData.NetTopologySuite.UriParser.Parsers; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; + +/// +/// Extensions to attach NetTopologySuite type mapping to an Edm model. +/// +public static class ODataNetTopologySuiteEdmModelExtensions +{ + /// + /// Attaches the NetTopologySuite type mapper to the given Edm model. + /// + public static IEdmModel UseNetTopologySuite(this IEdmModel model) + { + if (model == null) + { + throw Error.ArgumentNull(nameof(model)); + } + + model.SetTypeMapper(ODataNetTopologySuiteTypeMapper.Instance); + // TODO: Make parser target Edm.Geometry* and Edm.Geography* or leave it general? + model.AddCustomUriLiteralParser(ODataNetTopologySuiteUriLiteralParser.Instance); + + return model; + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologySuiteODataConventionModelBuilderExtensions.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologySuiteODataConventionModelBuilderExtensions.cs new file mode 100644 index 000000000..037a9142e --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Extensions/ODataNetTopologySuiteODataConventionModelBuilderExtensions.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Common; +using Microsoft.AspNetCore.OData.NetTopologySuite.Conventions; +using Microsoft.AspNetCore.OData.NetTopologySuite.Providers; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; + +/// +/// Extension methods to configure NetTopologySuite integration on an . +/// +public static class ODataNetTopologySuiteODataConventionModelBuilderExtensions +{ + /// + /// Adds the NetTopologySuite Edm type mapping provider and necessary spatial conventions. + /// + public static ODataConventionModelBuilder UseNetTopologySuite(this ODataConventionModelBuilder builder) + { + if (builder == null) + { + throw Error.ArgumentNull(nameof(builder)); + } + + // Add Edm type mapping provider for NetTopologySuite spatial types + builder.AddEdmTypeMappingProvider(new ODataNetTopologySuiteEdmTypeMappingProvider()); + + // Add geography conventions for properties and types + builder.AddModelConventions( + new GeographyAttributeEdmPropertyConvention(), + new GeographyAttributeEdmTypeConvention()); + + return builder; + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Deserialization/ODataSpatialNetTopologySuiteDeserializer.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Deserialization/ODataSpatialNetTopologySuiteDeserializer.cs new file mode 100644 index 000000000..ca8a5c9ab --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Deserialization/ODataSpatialNetTopologySuiteDeserializer.cs @@ -0,0 +1,201 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.ObjectModel; +using Microsoft.AspNetCore.OData.Formatter.Deserialization; +using Microsoft.AspNetCore.OData.NetTopologySuite.Common; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using Microsoft.Spatial; +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Deserialization; + +/// +/// Represents an that can read OData spatial types. +/// +internal class ODataSpatialNetTopologySuiteDeserializer : ODataSpatialDeserializer +{ + /// + /// Initializes a new instance of the class. + /// + public ODataSpatialNetTopologySuiteDeserializer() + : base() + { + } + + public override async Task ReadAsync(ODataMessageReader messageReader, Type type, ODataDeserializerContext readContext) + { + if (messageReader == null) + { + throw new ArgumentNullException(nameof(messageReader)); + } + + if (readContext == null) + { + throw new ArgumentNullException(nameof(readContext)); + } + + if (readContext.Path?.LastSegment is PropertySegment propertySegment) + { + // Trust the model to provide the correct Edm type for the NTS spatial property. + // The mapper only maps NTS types to Edm.Geometry* and we have no means at this point, + // specifically, no access to GeographyAttribute decorating the property on the CLR type + // to help us disambiguate between Edm.Geometry* and Edm.Geography* other than the model + // that has the correct type information set during model creation. + IEdmTypeReference edmType = propertySegment.Property.Type; + + ODataProperty property = await messageReader.ReadPropertyAsync(edmType).ConfigureAwait(false); + return ReadInline(property, edmType, readContext); + } + + return await base.ReadAsync(messageReader, type, readContext).ConfigureAwait(false); + } + + /// + public override object ReadInline(object item, IEdmTypeReference edmType, ODataDeserializerContext readContext) + { + if (item == null) + { + return null; + } + + if (readContext == null) + { + throw Error.ArgumentNull(nameof(readContext)); + } + + ODataProperty property = item as ODataProperty; + if (property != null) + { + item = property.Value; + } + + if (!(item is ISpatial spatialValue)) + { + // TODO: Use resource manager for error messages + throw new ArgumentException($"The item must be of type ISpatial, but was '{item.GetType().FullName}'.", nameof(item)); + } + + return ConvertSpatialValue(spatialValue); + } + + private object ConvertSpatialValue(ISpatial spatialValue) + { + switch (spatialValue) + { + case GeometryPoint: + GeometryPoint geometryPoint = (GeometryPoint)spatialValue; + + return CreatePoint( + geometryPoint.X, + geometryPoint.Y, + geometryPoint.Z, + geometryPoint.M, + geometryPoint.CoordinateSystem?.EpsgId); + case GeographyPoint: + GeographyPoint geographyPoint = (GeographyPoint)spatialValue; + + return CreatePoint( + geographyPoint.Longitude, + geographyPoint.Latitude, + geographyPoint.Z, + geographyPoint.M, + geographyPoint.CoordinateSystem?.EpsgId); + + case GeometryLineString: + GeometryLineString geometryLineString = (GeometryLineString)spatialValue; + + return CreateLineString(geometryLineString.Points, geometryLineString.CoordinateSystem?.EpsgId); + case GeographyLineString: + GeographyLineString geographyLineString = (GeographyLineString)spatialValue; + + return CreateLineString(geographyLineString.Points, geographyLineString.CoordinateSystem?.EpsgId); + default: + throw new NotSupportedException($"The type '{spatialValue.GetType().FullName}' is not supported."); + } + } + + private static Point CreatePoint(double x, double y, double? z, double? m, int? srid) + { + var coordinate = CreateCoordinate(x, y, z, m); + // TODO: Is it better to use a GeometryFactory? + var point = new Point(coordinate); + if (!IsNullOrNaN(srid)) + { + point.SRID = (int)srid; + } + + return point; + } + + private static LineString CreateLineString(ReadOnlyCollection points, int? srid) + { + var coordinates = new Coordinate[points.Count]; + for (int i = 0; i < points.Count; i++) + { + GeometryPoint geometryPoint = points[i]; + coordinates[i] = CreateCoordinate(geometryPoint.X, geometryPoint.Y, geometryPoint.Z, geometryPoint.M); + } + + var lineString = new LineString(coordinates); + if (!IsNullOrNaN(srid)) + { + lineString.SRID = (int)srid; + } + + return lineString; + } + + private static LineString CreateLineString(ReadOnlyCollection points, int? srid) + { + Coordinate[] coordinates = new Coordinate[points.Count]; + for (int i = 0; i < points.Count; i++) + { + GeographyPoint geographyPoint = points[i]; + coordinates[i] = CreateCoordinate(geographyPoint.Longitude, geographyPoint.Latitude, geographyPoint.Z, geographyPoint.M); + } + + LineString lineString = new LineString(coordinates); + if (!IsNullOrNaN(srid)) + { + lineString.SRID = (int)srid; + } + + return lineString; + } + + private static Coordinate CreateCoordinate(double x, double y, double? z, double? m) + { + bool hasZ = !IsNullOrNaN(z); + bool hasM = !IsNullOrNaN(m); + + // Most common scenario: only X and Y + if (!hasZ && !hasM) + { + return new Coordinate(x, y); + } + else if (hasZ && hasM) + { + return new CoordinateZM(x, y, z.Value, m.Value); + } + else if (hasZ) + { + return new CoordinateZ(x, y, z.Value); + } + else // hasM + { + return new CoordinateM(x, y, m.Value); + } + } + + private static bool IsNullOrNaN(double? value) + { + return !value.HasValue || double.IsNaN(value.Value); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/GeometryCollectionConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/GeometryCollectionConverter.cs new file mode 100644 index 000000000..8f1622601 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/GeometryCollectionConverter.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; +using GeometryCollection = NetTopologySuite.Geometries.GeometryCollection; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal class GeometryCollectionConverter : ISpatialConverter +{ + public bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, Geometry geometry) + { + return geometry is GeometryCollection + && (primitiveTypeKind == EdmPrimitiveTypeKind.GeometryCollection + || primitiveTypeKind == EdmPrimitiveTypeKind.GeographyCollection); + } + + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + SpatialConverter converter = SpatialConverter.For(primitiveType, geometry.SRID); + + return converter.Convert((GeometryCollection)geometry); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/ISpatialConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/ISpatialConverter.cs new file mode 100644 index 000000000..30dca2cc8 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/ISpatialConverter.cs @@ -0,0 +1,18 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using NetTopologySuite.Geometries; +using ISpatial = Microsoft.Spatial.ISpatial; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal interface ISpatialConverter +{ + bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, Geometry geometry); + ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType); +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/ISpatialConverterRegistry.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/ISpatialConverterRegistry.cs new file mode 100644 index 000000000..ee48bd04c --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/ISpatialConverterRegistry.cs @@ -0,0 +1,17 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal interface ISpatialConverterRegistry +{ + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType); +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/LineStringConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/LineStringConverter.cs new file mode 100644 index 000000000..5d7d5c96d --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/LineStringConverter.cs @@ -0,0 +1,23 @@ +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; +using LineString = NetTopologySuite.Geometries.LineString; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal class LineStringConverter : ISpatialConverter +{ + public bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, Geometry geometry) + { + return geometry is LineString + && (primitiveTypeKind == EdmPrimitiveTypeKind.GeometryLineString + || primitiveTypeKind == EdmPrimitiveTypeKind.GeographyLineString); + } + + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + SpatialConverter converter = SpatialConverter.For(primitiveType, geometry.SRID); + + return converter.Convert((LineString)geometry); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiLineStringConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiLineStringConverter.cs new file mode 100644 index 000000000..aa805a204 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiLineStringConverter.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; +using MultiLineString = NetTopologySuite.Geometries.MultiLineString; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal class MultiLineStringConverter : ISpatialConverter +{ + public bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, Geometry geometry) + { + return geometry is MultiLineString + && (primitiveTypeKind == EdmPrimitiveTypeKind.GeometryMultiLineString + || primitiveTypeKind == EdmPrimitiveTypeKind.GeographyMultiLineString); + } + + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + SpatialConverter converter = SpatialConverter.For(primitiveType, geometry.SRID); + + return converter.Convert((MultiLineString)geometry); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiPointConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiPointConverter.cs new file mode 100644 index 000000000..ccf45c1c0 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiPointConverter.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; +using MultiPoint = NetTopologySuite.Geometries.MultiPoint; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal class MultiPointConverter : ISpatialConverter +{ + public bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, Geometry geometry) + { + return geometry is MultiPoint + && (primitiveTypeKind == EdmPrimitiveTypeKind.GeometryMultiPoint + || primitiveTypeKind == EdmPrimitiveTypeKind.GeographyMultiPoint); + } + + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + SpatialConverter converter = SpatialConverter.For(primitiveType, geometry.SRID); + + return converter.Convert((MultiPoint)geometry); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiPolygonConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiPolygonConverter.cs new file mode 100644 index 000000000..34869abbd --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/MultiPolygonConverter.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; +using MultiPolygon = NetTopologySuite.Geometries.MultiPolygon; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal class MultiPolygonConverter : ISpatialConverter +{ + public bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, Geometry geometry) + { + return geometry is MultiPolygon + && (primitiveTypeKind == EdmPrimitiveTypeKind.GeometryMultiPolygon + || primitiveTypeKind == EdmPrimitiveTypeKind.GeographyMultiPolygon); + } + + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + SpatialConverter converter = SpatialConverter.For(primitiveType, geometry.SRID); + + return converter.Convert((MultiPolygon)geometry); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/PointConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/PointConverter.cs new file mode 100644 index 000000000..0f22daf3f --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/PointConverter.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; +using Point = NetTopologySuite.Geometries.Point; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal class PointConverter : ISpatialConverter +{ + public bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, Geometry geometry) + { + return geometry is Point + && (primitiveTypeKind == EdmPrimitiveTypeKind.GeometryPoint + || primitiveTypeKind == EdmPrimitiveTypeKind.GeographyPoint); + } + + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + SpatialConverter converter = SpatialConverter.For(primitiveType, geometry.SRID); + + return converter.Convert((Point)geometry); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/PolygonConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/PolygonConverter.cs new file mode 100644 index 000000000..169a1ea96 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/PolygonConverter.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; +using Polygon = NetTopologySuite.Geometries.Polygon; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal class PolygonConverter : ISpatialConverter +{ + public bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, Geometry geometry) + { + return geometry is Polygon + && (primitiveTypeKind == EdmPrimitiveTypeKind.GeometryPolygon + || primitiveTypeKind == EdmPrimitiveTypeKind.GeographyPolygon); + } + + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + SpatialConverter converter = SpatialConverter.For(primitiveType, geometry.SRID); + + return converter.Convert((Polygon)geometry); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/SpatialConverter.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/SpatialConverter.cs new file mode 100644 index 000000000..8d9df9c85 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/SpatialConverter.cs @@ -0,0 +1,821 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.OData.NetTopologySuite.Common; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyCollection = Microsoft.Spatial.GeographyCollection; +using MsGeographyFactory = Microsoft.Spatial.GeographyFactory; +using MsGeometryCollection = Microsoft.Spatial.GeometryCollection; +using MsGeometryFactory = Microsoft.Spatial.GeometryFactory; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometry = NetTopologySuite.Geometries.Geometry; +using NtsGeometryCollection = NetTopologySuite.Geometries.GeometryCollection; +using NtsLineString = NetTopologySuite.Geometries.LineString; +using NtsMultiLineString = NetTopologySuite.Geometries.MultiLineString; +using NtsMultiPoint = NetTopologySuite.Geometries.MultiPoint; +using NtsMultiPolygon = NetTopologySuite.Geometries.MultiPolygon; +using NtsPoint = NetTopologySuite.Geometries.Point; +using NtsPolygon = NetTopologySuite.Geometries.Polygon; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal sealed class SpatialConverter +{ + private readonly bool isGeography; // true => Geography*, false => Geometry* + private readonly CoordinateSystem coordinateSystem; + private const int DefaultGeographySrid = 4326; + private const int DefaultGeometrySrid = 0; + + #region Cached Delegates + + // ---------- LineString ---------- + private static readonly Action, double, double, double?, double?> GeometryLineStringLineToAction = + static (f, x, y, z, m) => f.LineTo(x, y, z, m); + + + private static readonly Action, double, double, double?, double?> GeographyLineStringLineToAction = + static (f, lat, lon, z, m) => f.LineTo(lat, lon, z, m); + + // ---------- Polygon ---------- + + private static readonly Action, double, double, double?, double?> GeometryPolygonRingAction = + static (f, x, y, z, m) => f.Ring(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyPolygonRingAction = + static (f, lat, lon, z, m) => f.Ring(lat, lon, z, m); + + private static readonly Action, double, double, double?, double?> GeometryPolygonLineToAction = + static (f, x, y, z, m) => f.LineTo(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyPolygonLineToAction = + static (f, lat, lon, z, m) => f.LineTo(lat, lon, z, m); + + // ---------- MultiPoint ---------- + private static readonly Action, double, double, double?, double?> GeometryMultiPointAction = + static (f, x, y, z, m) => f.Point(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyMultiPointAction = + static (f, lat, lon, z, m) => f.Point(lat, lon, z, m); + + // ---------- MultiLineString ---------- + private static readonly Action, double, double, double?, double?> GeometryMultiLineStringLineToAction = + static (f, x, y, z, m) => f.LineTo(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyMultiLineStringLineToAction = + static (f, lat, lon, z, m) => f.LineTo(lat, lon, z, m); + + // ---------- MultiPolygon ---------- + private static readonly Action, double, double, double?, double?> GeometryMultiPolygonRingAction = + static (f, x, y, z, m) => f.Ring(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyMultiPolygonRingAction = + static (f, lat, lon, z, m) => f.Ring(lat, lon, z, m); + + private static readonly Action, double, double, double?, double?> GeometryMultiPolygonLineToAction = + static (f, x, y, z, m) => f.LineTo(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyMultiPolygonLineToAction = + static (f, lat, lon, z, m) => f.LineTo(lat, lon, z, m); + + // ---------- GeometryCollection ---------- + private static readonly Action, double, double, double?, double?> GeometryCollectionPointAction = + static (f, x, y, z, m) => f.Point(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyCollectionPointAction = + static (f, lat, lon, z, m) => f.Point(lat, lon, z, m); + + private static readonly Func, GeometryFactory> GeometryCollectionLineStringFunc + = static f => f.LineString(); + + private static readonly Func, GeographyFactory> GeographyCollectionLineStringFunc + = static f => f.LineString(); + + private static readonly Action, double, double, double?, double?> GeometryCollectionLineToAction = + static (f, x, y, z, m) => f.LineTo(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyCollectionLineToAction = + static (f, lat, lon, z, m) => f.LineTo(lat, lon, z, m); + + private static readonly Func, GeometryFactory> GeometryCollectionPolygonAction + = static f => f.Polygon(); + + private static readonly Func, GeographyFactory> GeographyCollectionPolygonAction + = static f => f.Polygon(); + + private static readonly Action, double, double, double?, double?> GeometryCollectionRingAction = + static (f, x, y, z, m) => f.Ring(x, y, z, m); + + private static readonly Action, double, double, double?, double?> GeographyCollectionRingAction = + static (f, lat, lon, z, m) => f.Ring(lat, lon, z, m); + + #endregion Cached Delegates + + private SpatialConverter(bool geography, int srid) + { + this.isGeography = geography; + // Use the static default coordinate systems for the common SRIDs + if (geography) + { + this.coordinateSystem = srid == DefaultGeographySrid ? + CoordinateSystem.DefaultGeography : CoordinateSystem.Geography(srid); + } + else + { + this.coordinateSystem = srid == DefaultGeometrySrid ? + CoordinateSystem.DefaultGeometry : CoordinateSystem.Geometry(srid); + } + } + + public static SpatialConverter For(IEdmPrimitiveTypeReference primitiveType, int srid) + { + if (primitiveType == null) + { + throw Error.ArgumentNull(nameof(primitiveType)); + } + + bool isGeography = primitiveType.PrimitiveKind() switch + { + // Geography + EdmPrimitiveTypeKind.GeographyPoint => true, + EdmPrimitiveTypeKind.GeographyLineString => true, + EdmPrimitiveTypeKind.GeographyPolygon => true, + EdmPrimitiveTypeKind.GeographyMultiPoint => true, + EdmPrimitiveTypeKind.GeographyMultiLineString => true, + EdmPrimitiveTypeKind.GeographyMultiPolygon => true, + EdmPrimitiveTypeKind.GeographyCollection => true, + EdmPrimitiveTypeKind.Geography => true, + // Geometry + EdmPrimitiveTypeKind.GeometryPoint => false, + EdmPrimitiveTypeKind.GeometryLineString => false, + EdmPrimitiveTypeKind.GeometryPolygon => false, + EdmPrimitiveTypeKind.GeometryMultiPoint => false, + EdmPrimitiveTypeKind.GeometryMultiLineString => false, + EdmPrimitiveTypeKind.GeometryMultiPolygon => false, + EdmPrimitiveTypeKind.GeometryCollection => false, + EdmPrimitiveTypeKind.Geometry => false, + _ => throw Error.InvalidOperation(SRResources.SpatialConverter_UnsupportedEdmType, typeof(SpatialConverter).FullName, primitiveType.PrimitiveKind().ToString()) + }; + + return new SpatialConverter(isGeography, srid); + } + + public ISpatial Convert(NtsGeometry geometry) + { + if (geometry == null) + { + throw Error.ArgumentNull(nameof(geometry)); + } + + return geometry switch + { + NtsPoint point => Convert(point), + NtsLineString lineString => Convert(lineString), + NtsPolygon polygon => Convert(polygon), + NtsMultiPoint multiPoint => Convert(multiPoint), + NtsMultiLineString multiLineString => Convert(multiLineString), + NtsMultiPolygon multiPolygon => Convert(multiPolygon), + NtsGeometryCollection geometryCollection => Convert(geometryCollection), + _ => throw Error.NotSupported(SRResources.SpatialConverter_UnsupportedGeometryType, typeof(SpatialConverter).FullName, geometry.GeometryType) + }; + } + + private ISpatial Convert(NtsPoint point) => + this.isGeography + ? BuildGeographyPoint(point) + : BuildGeometryPoint(point); + + private ISpatial Convert(NtsLineString lineString) => + this.isGeography + ? BuildGeographyLineString(lineString) + : BuildGeometryLineString(lineString); + + private ISpatial Convert(NtsPolygon polygon) => + this.isGeography + ? BuildGeographyPolygon(polygon) + : BuildGeometryPolygon(polygon); + + private ISpatial Convert(NtsMultiPoint multiPoint) => + this.isGeography + ? BuildGeographyMultiPoint(multiPoint) + : BuildGeometryMultiPoint(multiPoint); + + private ISpatial Convert(NtsMultiLineString multiLineString) => + this.isGeography + ? BuildGeographyMultiLineString(multiLineString) + : BuildGeometryMultiLineString(multiLineString); + + private ISpatial Convert(NtsMultiPolygon multiPolygon) => + this.isGeography + ? BuildGeographyMultiPolygon(multiPolygon) + : BuildGeometryMultiPolygon(multiPolygon); + + private ISpatial Convert(NtsGeometryCollection geometryCollection) => + this.isGeography + ? BuildGeographyCollection(geometryCollection) + : BuildGeometryCollection(geometryCollection); + + private GeometryPoint BuildGeometryPoint(NtsPoint point) + { + Debug.Assert(point != null, $"{point} != null"); + + if (point.Coordinate == null) + { + // POINT EMPTY + return MsGeometryFactory.Point(coordinateSystem).Build(); + } + + new GeometryMapper().Map(point.Coordinate, out double x, out double y, out double? z, out double? m); + + return MsGeometryFactory.Point(coordinateSystem, x, y, z, m).Build(); + } + + private GeographyPoint BuildGeographyPoint(NtsPoint point) + { + Debug.Assert(point != null, $"{point} != null"); + + if (point.Coordinate == null) + { + // POINT EMPTY + return MsGeographyFactory.Point(coordinateSystem).Build(); + } + + new GeographyMapper().Map(point.Coordinate, out double lat, out double lon, out double? z, out double? m); + + return MsGeographyFactory.Point(coordinateSystem, lat, lon, z, m).Build(); + } + + private GeometryLineString BuildGeometryLineString(NtsLineString lineString) + { + Debug.Assert(lineString != null, $"{lineString} != null"); + + GeometryFactory factory = MsGeometryFactory.LineString(coordinateSystem); + if (lineString.Coordinates == null || lineString.Coordinates.Length == 0) + { + // LINESTRING EMPTY + return factory.Build(); + } + + ConstructFromCoordinates( + factory, + lineString.Coordinates, + GeometryLineStringLineToAction, + default(GeometryMapper)); + + return factory.Build(); + } + + private GeographyLineString BuildGeographyLineString(NtsLineString lineString) + { + Debug.Assert(lineString != null, $"{lineString} != null"); + + GeographyFactory factory = MsGeographyFactory.LineString(coordinateSystem); + if (lineString.Coordinates == null || lineString.Coordinates.Length == 0) + { + // LINESTRING EMPTY + return factory.Build(); + } + + ConstructFromCoordinates( + factory, + lineString.Coordinates, + GeographyLineStringLineToAction, + default(GeographyMapper)); + + return factory.Build(); + } + + private GeometryPolygon BuildGeometryPolygon(NtsPolygon polygon) + { + Debug.Assert(polygon != null, $"{polygon} != null"); + + GeometryFactory factory = MsGeometryFactory.Polygon(coordinateSystem); + if (polygon.Shell == null) + { + // POLYGON EMPTY + return factory.Build(); + } + + ConstructFromPolygon( + factory, + polygon, + GeometryPolygonRingAction, + GeometryPolygonLineToAction, + default(GeometryMapper)); + + return factory.Build(); + } + + private GeographyPolygon BuildGeographyPolygon(NtsPolygon polygon) + { + Debug.Assert(polygon != null, $"{polygon} != null"); + + GeographyFactory factory = MsGeographyFactory.Polygon(coordinateSystem); + if (polygon.Shell == null) + { + // POLYGON EMPTY + return factory.Build(); + } + + ConstructFromPolygon( + factory, + polygon, + GeographyPolygonRingAction, + GeographyPolygonLineToAction, + default(GeographyMapper)); + + return factory.Build(); + } + + private GeometryMultiPoint BuildGeometryMultiPoint(NtsMultiPoint multiPoint) + { + Debug.Assert(multiPoint != null, $"{multiPoint} != null"); + + GeometryFactory factory = MsGeometryFactory.MultiPoint(coordinateSystem); + if (multiPoint.Geometries == null || multiPoint.Geometries.Length == 0) + { + // MULTIPOINT EMPTY + return factory.Build(); + } + + for (int i = 0; i < multiPoint.Geometries.Length; i++) + { + NtsPoint point = (NtsPoint)multiPoint.Geometries[i]; + + ConstructFromCoordinate( + factory, + point.Coordinate, + GeometryMultiPointAction, + default(GeometryMapper)); + } + + return factory.Build(); + } + + private GeographyMultiPoint BuildGeographyMultiPoint(NtsMultiPoint multiPoint) + { + Debug.Assert(multiPoint != null, $"{multiPoint} != null"); + GeographyFactory factory = MsGeographyFactory.MultiPoint(coordinateSystem); + if (multiPoint.Geometries == null || multiPoint.Geometries.Length == 0) + { + // MULTIPOINT EMPTY + return factory.Build(); + } + + for (int i = 0; i < multiPoint.Geometries.Length; i++) + { + NtsPoint point = (NtsPoint)multiPoint.Geometries[i]; + + ConstructFromCoordinate( + factory, + point.Coordinate, + GeographyMultiPointAction, + default(GeographyMapper)); + } + + return factory.Build(); + } + + private GeometryMultiLineString BuildGeometryMultiLineString(NtsMultiLineString multiLineString) + { + Debug.Assert(multiLineString != null, $"{multiLineString} != null"); + + GeometryFactory factory = MsGeometryFactory.MultiLineString(coordinateSystem); + if (multiLineString.Geometries == null || multiLineString.Geometries.Length == 0) + { + // MULTILINESTRING EMPTY + return factory.Build(); + } + + for (int i = 0; i < multiLineString.Geometries.Length; i++) + { + NtsLineString lineString = (NtsLineString)multiLineString.Geometries[i]; + + ConstructFromCoordinates( + factory.LineString(), + lineString.Coordinates, + GeometryMultiLineStringLineToAction, + default(GeometryMapper)); + } + + return factory.Build(); + } + + private GeographyMultiLineString BuildGeographyMultiLineString(NtsMultiLineString multiLineString) + { + Debug.Assert(multiLineString != null, $"{multiLineString} != null"); + + GeographyFactory factory = MsGeographyFactory.MultiLineString(coordinateSystem); + if (multiLineString.Geometries == null || multiLineString.Geometries.Length == 0) + { + // MULTILINESTRING EMPTY + return factory.Build(); + } + + for (int i = 0; i < multiLineString.Geometries.Length; i++) + { + NtsLineString lineString = (NtsLineString)multiLineString.Geometries[i]; + + ConstructFromCoordinates( + factory.LineString(), + lineString.Coordinates, + GeographyMultiLineStringLineToAction, + default(GeographyMapper)); + } + + return factory.Build(); + } + + private GeometryMultiPolygon BuildGeometryMultiPolygon(NtsMultiPolygon multiPolygon) + { + Debug.Assert(multiPolygon != null, $"{multiPolygon} != null"); + + GeometryFactory factory = MsGeometryFactory.MultiPolygon(coordinateSystem); + if (multiPolygon.Geometries == null || multiPolygon.Geometries.Length == 0) + { + // MULTIPOLYGON EMPTY + return factory.Build(); + } + + for (int i = 0; i < multiPolygon.Geometries.Length; i++) + { + NtsPolygon polygon = (NtsPolygon)multiPolygon.Geometries[i]; + + ConstructFromPolygon( + factory.Polygon(), + polygon, + GeometryMultiPolygonRingAction, + GeometryMultiPolygonLineToAction, + default(GeometryMapper)); + } + + return factory.Build(); + } + + private GeographyMultiPolygon BuildGeographyMultiPolygon(NtsMultiPolygon multiPolygon) + { + Debug.Assert(multiPolygon != null, $"{multiPolygon} != null"); + + GeographyFactory factory = MsGeographyFactory.MultiPolygon(coordinateSystem); + if (multiPolygon.Geometries == null || multiPolygon.Geometries.Length == 0) + { + // MULTIPOLYGON EMPTY + return factory.Build(); + } + + for (int i = 0; i < multiPolygon.Geometries.Length; i++) + { + NtsPolygon polygon = (NtsPolygon)multiPolygon.Geometries[i]; + + ConstructFromPolygon( + factory.Polygon(), + polygon, + GeographyMultiPolygonRingAction, + GeographyMultiPolygonLineToAction, + default(GeographyMapper)); + } + + return factory.Build(); + } + + private MsGeometryCollection BuildGeometryCollection(NtsGeometryCollection geometryCollection) + { + Debug.Assert(geometryCollection != null, $"{geometryCollection} != null"); + + GeometryFactory factory = MsGeometryFactory.Collection(coordinateSystem); + if (geometryCollection.Coordinates == null || geometryCollection.Geometries.Length == 0) + { + // GEOMETRYCOLLECTION EMPTY + return factory.Build(); + } + + ConstructFromGeometryCollection(factory, geometryCollection, default(GeometryMapper)); + + return factory.Build(); + } + + private MsGeographyCollection BuildGeographyCollection(NtsGeometryCollection geometryCollection) + { + Debug.Assert(geometryCollection != null, $"{geometryCollection} != null"); + + GeographyFactory factory = MsGeographyFactory.Collection(coordinateSystem); + if (geometryCollection.Coordinates == null || geometryCollection.Geometries.Length == 0) + { + // GEOMETRYCOLLECTION EMPTY + return factory.Build(); + } + + ConstructFromGeometryCollection(factory, geometryCollection, default(GeographyMapper)); + + return factory.Build(); + } + + private static void ConstructFromCoordinate( + TFactory factory, + NtsCoordinate coordinate, + Action startPoint, + TMapper mapper) + where TMapper : struct, ICoordinateMapper + { + mapper.Map(coordinate, out double latOrX, out double lonOrY, out double? elevation, out double? measure); + startPoint(factory, latOrX, lonOrY, elevation, measure); + } + + private static void ConstructFromCoordinates( + TFactory factory, + NtsCoordinate[] coordinates, + Action lineTo, + TMapper mapper) + where TMapper : struct, ICoordinateMapper + { + Debug.Assert(factory != null, $"{factory} != null"); + Debug.Assert(coordinates != null, $"{coordinates} != null"); + Debug.Assert(lineTo != null, $"{lineTo} != null"); + + if (coordinates == null || coordinates.Length == 0) + { + return; + } + + for (int i = 0; i < coordinates.Length; i++) + { + mapper.Map(coordinates[i], out double latOrX, out double lonOrY, out double? elevation, out double? measure); + lineTo(factory, latOrX, lonOrY, elevation, measure); + } + } + + private static void ConstructFromCoordinates( + TFactory factory, + NtsCoordinate[] coordinates, + Action geoStart, + Action lineTo, + TMapper mapper) + where TMapper : struct, ICoordinateMapper + { + Debug.Assert(factory != null, $"{factory} != null"); + Debug.Assert(coordinates != null, $"{coordinates} != null"); + Debug.Assert(geoStart != null, $"{geoStart} != null"); + Debug.Assert(lineTo != null, $"{lineTo} != null"); + + if (coordinates == null || coordinates.Length == 0) + { + return; + } + + mapper.Map(coordinates[0], out double latOrX, out double lonOrY, out double? elevation, out double? measure); + geoStart(factory, latOrX, lonOrY, elevation, measure); + + for (int i = 1; i < coordinates.Length; i++) + { + mapper.Map(coordinates[i], out latOrX, out lonOrY, out elevation, out measure); + lineTo(factory, latOrX, lonOrY, elevation, measure); + } + } + + private static void ConstructFromPolygon( + TFactory factory, + NtsPolygon polygon, + Action ringStart, + Action lineTo, + TMapper mapper) + where TMapper : struct, ICoordinateMapper + { + Debug.Assert(factory != null, $"{factory} != null"); + Debug.Assert(polygon != null, $"{polygon} != null"); + Debug.Assert(ringStart != null, $"{ringStart} != null"); + Debug.Assert(lineTo != null, $"{lineTo} != null"); + + // TODO: Check polygon not null + + // Outer ring + ConstructFromCoordinates(factory, polygon.Shell?.Coordinates, ringStart, lineTo, mapper); + + // Holes + for (int i = 0; i < polygon.Holes.Length; i++) + { + ConstructFromCoordinates(factory, polygon.Holes[i].Coordinates, ringStart, lineTo, mapper); + } + } + + private void ConstructFromGeometryCollection( + GeometryFactory collectionFactory, + NtsGeometryCollection geometryCollection, + TMapper mapper) + where TMapper : struct, ICoordinateMapper + { + Debug.Assert(collectionFactory != null, $"{collectionFactory} != null"); + Debug.Assert(geometryCollection != null, $"{geometryCollection} != null"); + + ConstructFromGeometryCollectionCore( + geometryCollection, + collectionFactory: collectionFactory, + // Shared + pointStart: GeometryCollectionPointAction, + lineTo: GeometryCollectionLineToAction, + // LineString + lineStringFactory: collectionFactory.LineString, + // Polygon + polygonFactory: collectionFactory.Polygon, + ringStart: GeometryCollectionRingAction, + // MultiPoint + multiPointFactory: collectionFactory.MultiPoint, + // MultiLineString + multiLineStringFactory: collectionFactory.MultiLineString, + multiLineStringItemFactory: GeometryCollectionLineStringFunc, + // MultiPolygon + multiPolygonFactory: collectionFactory.MultiPolygon, + multiPolygonItemFactory: GeometryCollectionPolygonAction, + // Nested GeometryCollection + nestedGeometryCollectionFactory: nestedGeometryCollection => ConstructFromGeometryCollection( + collectionFactory.Collection(), + nestedGeometryCollection, + mapper), + mapper); + } + + private void ConstructFromGeometryCollection( + GeographyFactory collectionFactory, + NtsGeometryCollection geometryCollection, + TMapper mapper) + where TMapper : struct, ICoordinateMapper + { + Debug.Assert(geometryCollection != null, $"{geometryCollection} != null"); + Debug.Assert(collectionFactory != null, $"{collectionFactory} != null"); + + ConstructFromGeometryCollectionCore( + geometryCollection, + collectionFactory: collectionFactory, + // Shared + pointStart: GeographyCollectionPointAction, + lineTo: GeographyCollectionLineToAction, + // LineString + lineStringFactory: collectionFactory.LineString, + // Polygon + polygonFactory: collectionFactory.Polygon, + ringStart: GeographyCollectionRingAction, + // MultiPoint + multiPointFactory: collectionFactory.MultiPoint, + // MultiLineString + multiLineStringFactory: collectionFactory.MultiLineString, + multiLineStringItemFactory: GeographyCollectionLineStringFunc, + // MultiPolygon + multiPolygonFactory: collectionFactory.MultiPolygon, + multiPolygonItemFactory: GeographyCollectionPolygonAction, + // Nested GeometryCollection + nestedGeometryCollectionFactory: nestedGeometryCollection => ConstructFromGeometryCollection( + collectionFactory.Collection(), + nestedGeometryCollection, + mapper), + mapper); + } + + private void ConstructFromGeometryCollectionCore( + NtsGeometryCollection geometryCollection, + TFactory collectionFactory, + // Shared + Action pointStart, + Action lineTo, + // LineString + Func lineStringFactory, + // Polygon + Func polygonFactory, + Action ringStart, + // MultiPoint + Func multiPointFactory, + // MultiLineString + Func multiLineStringFactory, + Func multiLineStringItemFactory, + // MultiPolygon + Func multiPolygonFactory, + Func multiPolygonItemFactory, + // Nested GeometryCollection + Action nestedGeometryCollectionFactory, + TMapper mapper) + where TMapper : struct, ICoordinateMapper + { + Debug.Assert(collectionFactory != null, $"{collectionFactory} != null"); + Debug.Assert(pointStart != null, $"{pointStart} != null"); + Debug.Assert(lineTo != null, $"{lineTo} != null"); + Debug.Assert(lineStringFactory != null, $"{lineStringFactory} != null"); + Debug.Assert(polygonFactory != null, $"{polygonFactory} != null"); + Debug.Assert(ringStart != null, $"{ringStart} != null"); + Debug.Assert(multiPointFactory != null, $"{multiPointFactory} != null"); + Debug.Assert(multiLineStringFactory != null, $"{multiLineStringFactory} != null"); + Debug.Assert(multiLineStringItemFactory != null, $"{multiLineStringItemFactory} != null"); + Debug.Assert(multiPolygonFactory != null, $"{multiPolygonFactory} != null"); + Debug.Assert(multiPolygonItemFactory != null, $"{multiPolygonItemFactory} != null"); + Debug.Assert(nestedGeometryCollectionFactory != null, $"{nestedGeometryCollectionFactory} != null"); + // TODO: Because of recursion, perform an actual null check for geometryCollection + + for (int i = 0; i < geometryCollection.Geometries.Length; i++) + { + NtsGeometry geometry = geometryCollection.Geometries[i]; + + switch (geometry) + { + case NtsPoint point: + { + ConstructFromCoordinate(collectionFactory, point.Coordinate, pointStart, mapper); + + break; + } + case NtsLineString lineString: + { + TFactory factory = lineStringFactory(); + ConstructFromCoordinates(factory, lineString.Coordinates, lineTo, mapper); + + break; + } + case NtsPolygon polygon: + { + TFactory factory = polygonFactory(); + ConstructFromPolygon(factory, polygon, ringStart, lineTo, mapper); + + break; + } + case NtsMultiPoint multiPoint: + { + TFactory factory = multiPointFactory(); + for (int j = 0; j < multiPoint.Geometries.Length; j++) + { + NtsPoint point = (NtsPoint)multiPoint.Geometries[j]; + ConstructFromCoordinate(factory, point.Coordinate, pointStart, mapper); + } + + break; + } + case NtsMultiLineString multiLineString: + { + TFactory factory = multiLineStringFactory(); + for (int j = 0; j < multiLineString.Geometries.Length; j++) + { + NtsLineString lineString = (NtsLineString)multiLineString.Geometries[j]; + TFactory itemFactory = multiLineStringItemFactory(factory); + ConstructFromCoordinates(itemFactory, lineString.Coordinates, lineTo, mapper); + } + + break; + } + case NtsMultiPolygon multiPolygon: + { + TFactory factory = multiPolygonFactory(); + for (int j = 0; j < multiPolygon.Geometries.Length; j++) + { + NtsPolygon polygon = (NtsPolygon)multiPolygon.Geometries[j]; + TFactory itemFactory = multiPolygonItemFactory(factory); + ConstructFromPolygon(itemFactory, polygon, ringStart, lineTo, mapper); + } + + break; + } + case NtsGeometryCollection nestedGeometryCollection: + nestedGeometryCollectionFactory(nestedGeometryCollection); + + break; + default: + throw Error.NotSupported( + SRResources.SpatialConverter_UnsupportedGeometryType, + typeof(SpatialConverter).FullName, + geometry.GeometryType); + } + } + } + + // Map NTS Coordinate to (latOrX,lonOrY,z,m) with correct orientation for Geometry/Geography. + // For Geometry: (latOrX,lonOrY) = (X,Y), for Geography: (latOrX,lonOrY) = (Y,X) + + // NOTE: We are using the mapper pattern to prevent allocation of delegate instances at call sites, + // support inlining, and enable better optimization by JIT. + private interface ICoordinateMapper + { + void Map(NtsCoordinate coordinate, out double latOrX, out double lonOrY, out double? z, out double? m); + } + + private readonly struct GeometryMapper : ICoordinateMapper + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Map(NtsCoordinate coordinate, out double x, out double y, out double? z, out double? m) + { + z = double.IsNaN(coordinate.Z) ? null : coordinate.Z; + m = double.IsNaN(coordinate.M) ? null : coordinate.M; + x = coordinate.X; y = coordinate.Y; + } + } + + private readonly struct GeographyMapper : ICoordinateMapper + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Map(NtsCoordinate coordinate, out double lat, out double lon, out double? z, out double? m) + { + z = double.IsNaN(coordinate.Z) ? null : coordinate.Z; + m = double.IsNaN(coordinate.M) ? null : coordinate.M; + lat = coordinate.Y; lon = coordinate.X; // lat, lon + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/SpatialConverterRegistry.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/SpatialConverterRegistry.cs new file mode 100644 index 000000000..02c9d09e4 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/Converters/SpatialConverterRegistry.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Common; +using Microsoft.OData.Edm; +using Geometry = NetTopologySuite.Geometries.Geometry; +using ISpatial = Microsoft.Spatial.ISpatial; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; + +internal sealed class SpatialConverterRegistry : ISpatialConverterRegistry +{ + private readonly List converters; + + public SpatialConverterRegistry(IEnumerable converters) + { + this.converters = new List(converters); + } + + public ISpatial Convert(Geometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + for (int i = 0; i < this.converters.Count; i++) + { + if (this.converters[i].CanConvert(primitiveType.PrimitiveKind(), geometry)) + { + return this.converters[i].Convert(geometry, primitiveType); + } + } + + throw Error.InvalidOperation(SRResources.CannotWriteType, typeof(ODataSpatialNetTopologySuiteSerializer), geometry.GetType().FullName); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/ODataSpatialNetTopologySuiteSerializer.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/ODataSpatialNetTopologySuiteSerializer.cs new file mode 100644 index 000000000..04cbbf825 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Formatter/Serialization/ODataSpatialNetTopologySuiteSerializer.cs @@ -0,0 +1,197 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Formatter.Serialization; +using Microsoft.AspNetCore.OData.NetTopologySuite.Common; +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using NetTopologySuite.Geometries; +using Geometry = NetTopologySuite.Geometries.Geometry; +using GeometryFactory = Microsoft.Spatial.GeometryFactory; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization; + +/// +/// Represents an for serializing spatial types. +/// +internal class ODataSpatialNetTopologySuiteSerializer : ODataSpatialSerializer +{ + private readonly ISpatialConverterRegistry spatialConverterRegistry; + + /// + /// Initializes a new instance of . + /// + public ODataSpatialNetTopologySuiteSerializer(ISpatialConverterRegistry spatialConverterRegistry) + : base() + { + Error.ArgumentNull(nameof(spatialConverterRegistry)); + + this.spatialConverterRegistry = spatialConverterRegistry; + } + + /// + /// Creates an for the object represented by . + /// + /// The primitive value. + /// The EDM primitive type of the value. + /// The serializer write context. + /// The created . + public override ODataPrimitiveValue CreateODataPrimitiveValue( + object graph, + IEdmPrimitiveTypeReference primitiveType, + ODataSerializerContext writeContext) + { + return CreatePrimitive(graph, primitiveType, writeContext); + } + + private ODataPrimitiveValue CreatePrimitive( + object value, + IEdmPrimitiveTypeReference primitiveType, + ODataSerializerContext writeContext) + { + if (value == null) + { + return null; + } + + if (!primitiveType.IsSpatial()) + { + throw Error.InvalidOperation(SRResources.CannotWriteType, typeof(ODataSpatialNetTopologySuiteSerializer), primitiveType.FullName()); + } + + if (!(value is Geometry)) + { + throw Error.InvalidOperation(SRResources.CannotWriteType, typeof(ODataSpatialNetTopologySuiteSerializer), value.GetType().FullName); + } + + ISpatial spatialValue = this.spatialConverterRegistry.Convert((Geometry)value, primitiveType); + ODataPrimitiveValue primitive = new ODataPrimitiveValue(spatialValue); + + if (writeContext != null) + { + // TODO: Verify expected behavior for spatial values based on metadata level. + AddTypeNameAnnotationAsNeeded(primitive, primitiveType, writeContext.MetadataLevel); + } + + return primitive; + } + + private static GeometryPoint ConvertToGeometryPoint(Point point) + { + bool isZNullOrNaN = IsNullOrNaN(point.Z); + bool isMNullOrNaN = IsNullOrNaN(point.M); + + if (isZNullOrNaN && isMNullOrNaN) + { + // Only X and Y + return GeometryPoint.Create(point.Y, point.X); + } + else if (!isZNullOrNaN && isMNullOrNaN) + { + // X, Y, Z + return GeometryPoint.Create(point.Y, point.X, point.Z); + } + else + { + // X, Y, Z, M + return GeometryPoint.Create(point.Y, point.X, point.Z, point.M); + } + } + + private static GeometryLineString ConvertToGeometryLineString(LineString lineString) + { + CoordinateSystem coordinateSystem = CoordinateSystem.Geometry(lineString.SRID); + Coordinate[] coordinates = lineString.Coordinates; + GeometryFactory geometryLineStringFactory; + + Coordinate coordinate = coordinates[0]; + if (IsNullOrNaN(coordinate.Z) && IsNullOrNaN(coordinate.M)) // Most common case + { + geometryLineStringFactory = GeometryFactory.LineString(coordinateSystem, coordinate.X, coordinate.Y); + } + else // X, Y, Z, M + { + geometryLineStringFactory = GeometryFactory.LineString(coordinateSystem, coordinate.X, coordinate.Y, coordinate.Z, coordinate.M); + } + + for (int i = 1; i < coordinates.Length; i++) + { + coordinate = coordinates[i]; + if (IsNullOrNaN(coordinate.Z) && IsNullOrNaN(coordinate.M)) + { + geometryLineStringFactory.LineTo(coordinate.X, coordinate.Y); + } + else // X, Y, Z, M + { + geometryLineStringFactory.LineTo(coordinate.X, coordinate.Y, coordinate.Z, coordinate.M); + } + } + + return geometryLineStringFactory.Build(); + } + + private static GeographyPoint ConvertToGeographyPoint(Point point) + { + bool isZNullOrNaN = IsNullOrNaN(point.Z); + bool isMNullOrNaN = IsNullOrNaN(point.M); + + if (isZNullOrNaN && isMNullOrNaN) + { + // Only X and Y + return GeographyPoint.Create(point.Y, point.X); + } + else if (!isZNullOrNaN && isMNullOrNaN) + { + // X, Y, Z + return GeographyPoint.Create(point.Y, point.X, point.Z); + } + else + { + // X, Y, Z, M + return GeographyPoint.Create(point.Y, point.X, point.Z, point.M); + } + } + + private static GeographyLineString ConvertToGeographyLineString(LineString lineString) + { + CoordinateSystem coordinateSystem = CoordinateSystem.Geography(lineString.SRID); + Coordinate[] coordinates = lineString.Coordinates; + GeographyFactory geographyLineStringFactory; + + Coordinate coordinate = coordinates[0]; + if (IsNullOrNaN(coordinate.Z) && IsNullOrNaN(coordinate.M)) // Most common case + { + geographyLineStringFactory = GeographyFactory.LineString(coordinateSystem, coordinate.X, coordinate.Y); + } + else // X, Y, Z, M + { + geographyLineStringFactory = GeographyFactory.LineString(coordinateSystem, coordinate.X, coordinate.Y, coordinate.Z, coordinate.M); + } + + for (int i = 1; i < coordinates.Length; i++) + { + coordinate = coordinates[i]; + if (IsNullOrNaN(coordinate.Z) && IsNullOrNaN(coordinate.M)) + { + geographyLineStringFactory.LineTo(coordinate.X, coordinate.Y); + } + else // X, Y, Z, M + { + geographyLineStringFactory.LineTo(coordinate.X, coordinate.Y, coordinate.Z, coordinate.M); + } + } + + return geographyLineStringFactory.Build(); + } + + private static bool IsNullOrNaN(double? value) + { + return !value.HasValue || double.IsNaN(value.Value); + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Microsoft.AspNetCore.OData.NetTopologySuite.csproj b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Microsoft.AspNetCore.OData.NetTopologySuite.csproj new file mode 100644 index 000000000..b46087ce3 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Microsoft.AspNetCore.OData.NetTopologySuite.csproj @@ -0,0 +1,73 @@ + + + + net10.0 + enable + $(OutputPath)$(AssemblyName).xml + + false + Library + false + true + MIT + true + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + True + True + SRResources.resx + + + + + + ResXFileCodeGenerator + SRResources.Designer.cs + Microsoft.AspNetCore.OData.NetTopologySuite + + + + + + + + + + + ProductRoot=$(productBinPath);VersionNuGetSemantic=$(VersionNuGetSemantic) + $(NuspecProperties);ODataAspNetCorePackageDependency=$(ODataAspNetCorePackageDependency) + $(NuspecProperties);ODataModelBuilderPackageDependency=$(ODataModelBuilderPackageDependency) + $(NuspecProperties);ODataLibPackageDependency=$(ODataLibPackageDependency) + $(NuspecProperties);NightlyBuildVersion=$(NightlyBuildVersion) + $(NuspecProperties);SourcesRoot=$(SourcesRoot) + + + + diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Microsoft.AspNetCore.OData.NetTopologySuite.xml b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Microsoft.AspNetCore.OData.NetTopologySuite.xml new file mode 100644 index 000000000..92fc6c832 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Microsoft.AspNetCore.OData.NetTopologySuite.xml @@ -0,0 +1,365 @@ + + + + Microsoft.AspNetCore.OData.NetTopologySuite + + + + + Marks a property or CLR type so that NetTopologySuite geometry values are modeled as Edm geography types. + + + - When applied to a property whose CLR type derives from , + the property is mapped to the corresponding in the Edm.Geography* family + (for example, Point → GeographyPoint). + - When applied to a class, all properties on the type whose CLR types derive from + are treated as Edm.Geography*. + The behavior is enforced by the conventions + and + . + + + + + Formats the specified resource string using . + + A composite format string. + An object array that contains zero or more objects to format. + The formatted string. + + + + Creates an with the provided properties. + + The name of the parameter that caused the current exception. + The logged . + + + + Creates an . + + A composite format string explaining the reason for the exception. + An object array that contains zero or more objects to format. + The logged . + + + + Creates an . + + A composite format string explaining the reason for the exception. + An object array that contains zero or more objects to format. + The logged . + + + + A convention that applies to individual properties decorated with the . + If the property’s CLR type is a NetTopologySuite spatial type, it is mapped to the corresponding + OData geography Edm type (for example, PointEdm.GeographyPoint, + LineStringEdm.GeographyLineString). + + + + + Initializes a new instance of the class. + Configures the convention to match properties annotated with and + to map NetTopologySuite spatial CLR types to their Edm.Geography* primitive kinds. + + + + + + + + A convention that applies to entity or complex types decorated with the . + When applied, all NetTopologySuite spatial properties on the type are mapped to the corresponding + OData geography Edm types (for example, PointEdm.GeographyPoint, + LineStringEdm.GeographyLineString), rather than geometry types. + + + + + Initializes a new instance of the class. + Configures the convention to match types annotated with and + to map all NetTopologySuite spatial properties on those types to Edm.Geography* primitive kinds. + + + + + + + + Maps NetTopologySuite geometry CLR types to their corresponding OData Edm geography primitive kinds. + + + The mapping prefers the most specific known NTS shape (Point, LineString, Polygon, Multi*, GeometryCollection) + and falls back to . + Returns null if the supplied type is null or not an NTS geometry type. + + + + + Gets the Edm geography primitive kind that corresponds to the given NetTopologySuite CLR type. + + The CLR type to evaluate (e.g., Point, LineString). + + The matching for geography shapes, or null if no mapping exists. + + + + + A type mapper that registers mappings between NetTopologySuite (NTS) geometry CLR types + and OData Edm spatial primitive kinds. + + + - Maps NTS types to Edm.Geometry* kinds (e.g., ). + - This mapper complements the default mapper by adding non-standard mappings for NTS reference types. + - This mapper is attached to an as a direct-value annotation to scope it per model/route. + + + + + Singleton instance of . + + + + + Registers the NetTopologySuite geometry CLR types as Edm spatial primitive kinds. + + + Mappings are registered as non-standard to avoid overriding the default primitive mappings and + to allow providers/conventions to influence the final Edm kind (e.g., switch to geography). + + + + + Extension methods to register NetTopologySuite spatial formatters for ASP.NET Core OData. + + + This replaces the default OData spatial serializer/deserializer with implementations that read and write + NetTopologySuite geometry types. + + + + + Registers NetTopologySuite spatial serializers and deserializers. + + The service collection. + The same for chaining. + + This method is idempotent: it removes existing spatial formatter registrations and adds the NTS-based ones. + + + + + Extensions to attach NetTopologySuite type mapping to an Edm model. + + + + + Attaches the NetTopologySuite type mapper to the given Edm model. + + + + + Extension methods to configure NetTopologySuite integration on an . + + + + + Adds the NetTopologySuite Edm type mapping provider and necessary spatial conventions. + + + + + Represents an that can read OData spatial types. + + + + + Initializes a new instance of the class. + + + + + + + + Represents an for serializing spatial types. + + + + + Initializes a new instance of . + + + + + Creates an for the object represented by . + + The primitive value. + The EDM primitive type of the value. + The serializer write context. + The created . + + + + A strongly-typed resource class, for looking up localized strings, etc. + + + + + Returns the cached ResourceManager instance used by this class. + + + + + Overrides the current thread's CurrentUICulture property for all + resource lookups using this strongly typed resource class. + + + + + Looks up a localized string similar to {0} cannot write an object of type '{1}'.. + + + + + Looks up a localized string similar to The 'RootElementName' property is required on '{0}'.. + + + + + Looks up a localized string similar to '{0}' does not support '{0}' Edm type.. + + + + + Looks up a localized string similar to '{0}' does not support '{0}' geometry type.. + + + + + Maps NetTopologySuite (NTS) spatial CLR types to OData Edm primitive spatial types and vice versa. + + + - CLR to Edm: returns Edm.Geometry* kinds for NTS types (e.g., Point → Edm.GeometryPoint). + Per-property geography mapping can be enabled via conventions that set the target Edm kind. + - Edm to CLR: resolves both Edm.Geometry* and Edm.Geography* kinds to the corresponding NTS types, + since NTS represents both with the same CLR types. + - Nullability: when mapping Edm to CLR, the provider ensures the returned CLR type is compatible + with the Edm nullability (reference types are always nullable). + + + + + + + + + + + Parses OData spatial literals (geometry'…' / geography'…') into + NetTopologySuite instances. + + + + Supports the OData V4 spatial literal form: + geometry'SRID=<int>;<WKT>' or geography'SRID=<int>;<WKT>'. + The SRID=…; prefix is optional; when omitted, defaults are applied based on the + literal prefix: + + + geography ⇒ default SRID 4326 + geometry ⇒ default SRID 0 + + + The inner text (after removing the OData literal wrapper and optional SRID) is expected + to be a valid WKT string (e.g., POINT(-122.35 47.65), LINESTRING(…), etc.), + which is parsed by . + + + If the incoming literal is the keyword null, this parser returns null and + does not set parsing exception. + + + + + + Singleton instance of the parser. + + + + + Attempts to parse an OData URI spatial literal into a NetTopologySuite value + when the requested Edm type is spatial (Edm.Geometry* or Edm.Geography*). + + The OData literal text, e.g., geography'POINT(-122.35 47.65)'. + + The Edm type requested by the URI pipeline. Must be a primitive spatial type + (e.g., Edm.GeometryPoint, Edm.GeographyPoint). + + + Set to a describing the failure when this parser recognizes the literal + format but cannot parse it (e.g., invalid WKT). Set to null on success, or when the literal/type is + not recognized and the parser declines to handle it. + + + The parsed on success, or null if the literal is the keyword null, + if the target type is not spatial, or if this parser chooses not to handle the input. + + + Returning null with also null indicates that another parser + may attempt to handle the literal. Returning null with a non-null + signals that the parser recognized the target type/literal but parsing failed. + + + + + Tries to parse an OData spatial literal into a NetTopologySuite . + + The literal text, e.g., geometry'POINT(1 2)' or geography'SRID=4326;POINT(…)'. + Receives the parsed on success. + + Receives a describing the failure if the input is recognized + as a spatial literal but cannot be parsed (e.g., invalid WKT or SRID section). Otherwise null. + + true if parsing succeeded; otherwise false. + + + The method removes the geometry/geography OData literal prefix and surrounding single quotes. + If the remaining text does not start with SRID=…;, a default SRID is injected: + 4326 for geography and 0 for geometry. + + + The final string is parsed using from NetTopologySuite.IO. + + + + + + Attempts to remove a literal (geometry or geography) + from the start of (case-insensitive). + + The expected literal prefix. + The text to inspect; updated in-place if the prefix is removed. + + true if the prefix was found and removed; otherwise false. + + + This method is tolerant to case (e.g., GeOgRaPhY'…'), matching OData literal rules. + + + + + Removes the leading and trailing single quotes around a literal payload and unescapes doubled quotes. + + The quoted literal payload; updated in-place on success. + true if quotes were successfully removed and unescaped; otherwise false. + + Validates that the string is at least two characters long, begins and ends with a single quote, + and that any internal quote characters are doubled (OData single-quote escaping). Returns false + if the quoted form is invalid. + + + + diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..6d7e21f55 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/AssemblyInfo.cs @@ -0,0 +1,20 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Reflection; +using System.Resources; +using System.Runtime.CompilerServices; + +[assembly: AssemblyTitle("Microsoft.AspNetCore.OData.NetTopologySuite")] + +[assembly: AssemblyProduct("ASP.NET Core OData NetTopologySuite Integration")] + +[assembly: NeutralResourcesLanguage("en-US")] + +[assembly: AssemblyDescription("")] + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.OData.NetTopologySuite.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/SRResources.Designer.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/SRResources.Designer.cs new file mode 100644 index 000000000..270f362d9 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/SRResources.Designer.cs @@ -0,0 +1,99 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.NetTopologySuite { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class SRResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal SRResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.AspNetCore.OData.NetTopologySuite.Properties.SRResources", typeof(SRResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to {0} cannot write an object of type '{1}'.. + /// + internal static string CannotWriteType { + get { + return ResourceManager.GetString("CannotWriteType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'RootElementName' property is required on '{0}'.. + /// + internal static string RootElementNameMissing { + get { + return ResourceManager.GetString("RootElementNameMissing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' does not support '{0}' Edm type.. + /// + internal static string SpatialConverter_UnsupportedEdmType { + get { + return ResourceManager.GetString("SpatialConverter_UnsupportedEdmType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' does not support '{0}' geometry type.. + /// + internal static string SpatialConverter_UnsupportedGeometryType { + get { + return ResourceManager.GetString("SpatialConverter_UnsupportedGeometryType", resourceCulture); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/SRResources.resx b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/SRResources.resx new file mode 100644 index 000000000..8c550fb10 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Properties/SRResources.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} cannot write an object of type '{1}'. + + + The 'RootElementName' property is required on '{0}'. + + + '{0}' does not support '{0}' geometry type. + + + '{0}' does not support '{0}' Edm type. + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/Providers/ODataNetTopologySuiteEdmTypeMappingProvider.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Providers/ODataNetTopologySuiteEdmTypeMappingProvider.cs new file mode 100644 index 000000000..37daccdde --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/Providers/ODataNetTopologySuiteEdmTypeMappingProvider.cs @@ -0,0 +1,121 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Diagnostics; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder.Providers; +using Geometry = NetTopologySuite.Geometries.Geometry; +using GeometryCollection = NetTopologySuite.Geometries.GeometryCollection; +using LineString = NetTopologySuite.Geometries.LineString; +using MultiLineString = NetTopologySuite.Geometries.MultiLineString; +using MultiPoint = NetTopologySuite.Geometries.MultiPoint; +using MultiPolygon = NetTopologySuite.Geometries.MultiPolygon; +using Point = NetTopologySuite.Geometries.Point; +using Polygon = NetTopologySuite.Geometries.Polygon; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Providers; + +/// +/// Maps NetTopologySuite (NTS) spatial CLR types to OData Edm primitive spatial types and vice versa. +/// +/// +/// - CLR to Edm: returns Edm.Geometry* kinds for NTS types (e.g., Point → Edm.GeometryPoint). +/// Per-property geography mapping can be enabled via conventions that set the target Edm kind. +/// - Edm to CLR: resolves both Edm.Geometry* and Edm.Geography* kinds to the corresponding NTS types, +/// since NTS represents both with the same CLR types. +/// - Nullability: when mapping Edm to CLR, the provider ensures the returned CLR type is compatible +/// with the Edm nullability (reference types are always nullable). +/// +public class ODataNetTopologySuiteEdmTypeMappingProvider : IEdmTypeMappingProvider +{ + private static readonly EdmCoreModel _coreModel = EdmCoreModel.Instance; + + private static readonly Dictionary _spatialToEdmTypesMapping = + new[] + { + new KeyValuePair(typeof(Point), GetPrimitiveType(EdmPrimitiveTypeKind.GeometryPoint)), + new KeyValuePair(typeof(LineString), GetPrimitiveType(EdmPrimitiveTypeKind.GeometryLineString)), + new KeyValuePair(typeof(Polygon), GetPrimitiveType(EdmPrimitiveTypeKind.GeometryPolygon)), + new KeyValuePair(typeof(MultiPoint), GetPrimitiveType(EdmPrimitiveTypeKind.GeometryMultiPoint)), + new KeyValuePair(typeof(MultiLineString), GetPrimitiveType(EdmPrimitiveTypeKind.GeometryMultiLineString)), + new KeyValuePair(typeof(MultiPolygon), GetPrimitiveType(EdmPrimitiveTypeKind.GeometryMultiPolygon)), + new KeyValuePair(typeof(GeometryCollection), GetPrimitiveType(EdmPrimitiveTypeKind.GeometryCollection)), + new KeyValuePair(typeof(Geometry), GetPrimitiveType(EdmPrimitiveTypeKind.Geometry)), + } + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + private static readonly Dictionary _edmToSpatialTypesMapping = + new[] + { + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeometryPoint), typeof(Point)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeographyPoint), typeof(Point)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeometryLineString), typeof(LineString)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeographyLineString), typeof(LineString)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeometryPolygon), typeof(Polygon)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeographyPolygon), typeof(Polygon)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeometryMultiPoint), typeof(MultiPoint)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeographyMultiPoint), typeof(MultiPoint)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeometryMultiLineString), typeof(MultiLineString)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeographyMultiLineString), typeof(MultiLineString)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeometryMultiPolygon), typeof(MultiPolygon)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeographyMultiPolygon), typeof(MultiPolygon)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeometryCollection), typeof(GeometryCollection)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.GeographyCollection), typeof(GeometryCollection)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.Geometry), typeof(Geometry)), + new KeyValuePair(GetPrimitiveType(EdmPrimitiveTypeKind.Geography), typeof(Geometry)), + } + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + /// + public bool TryGetEdmType(Type clrType, out IEdmPrimitiveType primitiveType) + { + if (clrType.Equals(typeof(Point))) + { + + } + return _spatialToEdmTypesMapping.TryGetValue(clrType, out primitiveType); + } + + /// + public bool TryGetClrType(IEdmTypeReference edmTypeReference, out Type clrType) + { + clrType = null; + + if (edmTypeReference == null || !edmTypeReference.IsPrimitive()) + { + return false; + } + + foreach (KeyValuePair kvp in _edmToSpatialTypesMapping) + { + if (edmTypeReference.Definition.IsEquivalentTo(kvp.Key) && (!edmTypeReference.IsNullable || IsNullable(kvp.Value))) + { + clrType = kvp.Value; + return true; + } + } + + return false; + } + + private static IEdmPrimitiveType GetPrimitiveType(EdmPrimitiveTypeKind primitiveKind) + { + return _coreModel.GetPrimitiveType(primitiveKind); + } + + private static bool IsNullable(Type type) + { + Debug.Assert(type != null, "Type should not be null."); + + if (!type.IsValueType) + { + return true; // Reference types are nullable + } + + return Nullable.GetUnderlyingType(type) != null; // Nullable value types + } +} diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/PublicAPI.Shipped.txt b/src/Microsoft.AspNetCore.OData.NetTopologySuite/PublicAPI.Shipped.txt new file mode 100644 index 000000000..e69de29bb diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData.NetTopologySuite/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..c5001e26f --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/PublicAPI.Unshipped.txt @@ -0,0 +1,22 @@ +Microsoft.AspNetCore.OData.NetTopologySuite.Attributes.GeographyAttribute +Microsoft.AspNetCore.OData.NetTopologySuite.Attributes.GeographyAttribute.GeographyAttribute() -> void +Microsoft.AspNetCore.OData.NetTopologySuite.Conventions.GeographyAttributeEdmPropertyConvention +Microsoft.AspNetCore.OData.NetTopologySuite.Conventions.GeographyAttributeEdmPropertyConvention.GeographyAttributeEdmPropertyConvention() -> void +Microsoft.AspNetCore.OData.NetTopologySuite.Conventions.GeographyAttributeEdmTypeConvention +Microsoft.AspNetCore.OData.NetTopologySuite.Conventions.GeographyAttributeEdmTypeConvention.GeographyAttributeEdmTypeConvention() -> void +Microsoft.AspNetCore.OData.NetTopologySuite.Edm.ODataNetTopologySuiteTypeMapper +Microsoft.AspNetCore.OData.NetTopologySuite.Edm.ODataNetTopologySuiteTypeMapper.ODataNetTopologySuiteTypeMapper() -> void +Microsoft.AspNetCore.OData.NetTopologySuite.Extensions.ODataNetTopologyServiceCollectionExtensions +Microsoft.AspNetCore.OData.NetTopologySuite.Extensions.ODataNetTopologySuiteEdmModelExtensions +Microsoft.AspNetCore.OData.NetTopologySuite.Extensions.ODataNetTopologySuiteODataConventionModelBuilderExtensions +Microsoft.AspNetCore.OData.NetTopologySuite.GeographyAttribute +Microsoft.AspNetCore.OData.NetTopologySuite.Providers.ODataNetTopologySuiteEdmTypeMappingProvider +Microsoft.AspNetCore.OData.NetTopologySuite.Providers.ODataNetTopologySuiteEdmTypeMappingProvider.ODataNetTopologySuiteEdmTypeMappingProvider() -> void +Microsoft.AspNetCore.OData.NetTopologySuite.Providers.ODataNetTopologySuiteEdmTypeMappingProvider.TryGetClrType(Microsoft.OData.Edm.IEdmTypeReference edmTypeReference, out System.Type clrType) -> bool +Microsoft.AspNetCore.OData.NetTopologySuite.Providers.ODataNetTopologySuiteEdmTypeMappingProvider.TryGetEdmType(System.Type clrType, out Microsoft.OData.Edm.IEdmPrimitiveType primitiveType) -> bool +override Microsoft.AspNetCore.OData.NetTopologySuite.Conventions.GeographyAttributeEdmPropertyConvention.Apply(Microsoft.OData.ModelBuilder.PropertyConfiguration edmProperty, Microsoft.OData.ModelBuilder.StructuralTypeConfiguration structuralTypeConfiguration, System.Attribute attribute, Microsoft.OData.ModelBuilder.ODataConventionModelBuilder model) -> void +override Microsoft.AspNetCore.OData.NetTopologySuite.Conventions.GeographyAttributeEdmTypeConvention.Apply(Microsoft.OData.ModelBuilder.StructuralTypeConfiguration edmTypeConfiguration, Microsoft.OData.ModelBuilder.ODataConventionModelBuilder model, System.Attribute attribute) -> void +override Microsoft.AspNetCore.OData.NetTopologySuite.Edm.ODataNetTopologySuiteTypeMapper.RegisterSpatialMappings() -> void +static Microsoft.AspNetCore.OData.NetTopologySuite.Extensions.ODataNetTopologyServiceCollectionExtensions.AddODataNetTopologySuite(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection +static Microsoft.AspNetCore.OData.NetTopologySuite.Extensions.ODataNetTopologySuiteEdmModelExtensions.UseNetTopologySuite(this Microsoft.OData.Edm.IEdmModel model) -> Microsoft.OData.Edm.IEdmModel +static Microsoft.AspNetCore.OData.NetTopologySuite.Extensions.ODataNetTopologySuiteODataConventionModelBuilderExtensions.UseNetTopologySuite(this Microsoft.OData.ModelBuilder.ODataConventionModelBuilder builder) -> Microsoft.OData.ModelBuilder.ODataConventionModelBuilder \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData.NetTopologySuite/UriParser/Parsers/ODataNetTopologySuiteUriLiteralParser.cs b/src/Microsoft.AspNetCore.OData.NetTopologySuite/UriParser/Parsers/ODataNetTopologySuiteUriLiteralParser.cs new file mode 100644 index 000000000..9cbe034b7 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData.NetTopologySuite/UriParser/Parsers/ODataNetTopologySuiteUriLiteralParser.cs @@ -0,0 +1,251 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Diagnostics; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using NetTopologySuite; +using NetTopologySuite.Geometries; +using NetTopologySuite.IO; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.UriParser.Parsers; + +/// +/// Parses OData spatial literals (geometry'…' / geography'…') into +/// NetTopologySuite instances. +/// +/// +/// +/// Supports the OData V4 spatial literal form: +/// geometry'SRID=<int>;<WKT>' or geography'SRID=<int>;<WKT>'. +/// The SRID=…; prefix is optional; when omitted, defaults are applied based on the +/// literal prefix: +/// +/// +/// geography ⇒ default SRID 4326 +/// geometry ⇒ default SRID 0 +/// +/// +/// The inner text (after removing the OData literal wrapper and optional SRID) is expected +/// to be a valid WKT string (e.g., POINT(-122.35 47.65), LINESTRING(…), etc.), +/// which is parsed by . +/// +/// +/// If the incoming literal is the keyword null, this parser returns null and +/// does not set parsing exception. +/// +/// +internal class ODataNetTopologySuiteUriLiteralParser : IUriLiteralParser +{ + private const string LiteralPrefixGeometry = "geometry"; + private const string LiteralPrefixGeography = "geography"; + + /// + /// Singleton instance of the parser. + /// + public readonly static ODataNetTopologySuiteUriLiteralParser Instance = new ODataNetTopologySuiteUriLiteralParser(); + + private ODataNetTopologySuiteUriLiteralParser() + { + } + + /// + /// Attempts to parse an OData URI spatial literal into a NetTopologySuite value + /// when the requested Edm type is spatial (Edm.Geometry* or Edm.Geography*). + /// + /// The OData literal text, e.g., geography'POINT(-122.35 47.65)'. + /// + /// The Edm type requested by the URI pipeline. Must be a primitive spatial type + /// (e.g., Edm.GeometryPoint, Edm.GeographyPoint). + /// + /// + /// Set to a describing the failure when this parser recognizes the literal + /// format but cannot parse it (e.g., invalid WKT). Set to null on success, or when the literal/type is + /// not recognized and the parser declines to handle it. + /// + /// + /// The parsed on success, or null if the literal is the keyword null, + /// if the target type is not spatial, or if this parser chooses not to handle the input. + /// + /// + /// Returning null with also null indicates that another parser + /// may attempt to handle the literal. Returning null with a non-null + /// signals that the parser recognized the target type/literal but parsing failed. + /// + public object ParseUriStringToType(string text, IEdmTypeReference targetType, out UriLiteralParsingException parsingException) + { + parsingException = null; + + if (text == "null") + { + return null; + } + + IEdmPrimitiveTypeReference primitiveTargetType = targetType == null + ? null + : targetType.TypeKind() == EdmTypeKind.Primitive || targetType.TypeKind() == EdmTypeKind.TypeDefinition ? targetType.AsPrimitive() : null; + + if (primitiveTargetType == null) + { + return null; + } + + EdmPrimitiveTypeKind targetTypeKind = primitiveTargetType.PrimitiveKind(); + + if (targetTypeKind == EdmPrimitiveTypeKind.Geography || targetTypeKind == EdmPrimitiveTypeKind.Geometry) + { + Geometry geometry; + if (TryUriStringToGeometry(text, out geometry, out parsingException)) + { + return geometry; + } + } + + return null; + } + + /// + /// Tries to parse an OData spatial literal into a NetTopologySuite . + /// + /// The literal text, e.g., geometry'POINT(1 2)' or geography'SRID=4326;POINT(…)'. + /// Receives the parsed on success. + /// + /// Receives a describing the failure if the input is recognized + /// as a spatial literal but cannot be parsed (e.g., invalid WKT or SRID section). Otherwise null. + /// + /// true if parsing succeeded; otherwise false. + /// + /// + /// The method removes the geometry/geography OData literal prefix and surrounding single quotes. + /// If the remaining text does not start with SRID=…;, a default SRID is injected: + /// 4326 for geography and 0 for geometry. + /// + /// + /// The final string is parsed using from NetTopologySuite.IO. + /// + /// + private static bool TryUriStringToGeometry(string text, out Geometry targetValue, out UriLiteralParsingException parsingException) + { + parsingException = null; + + int defaultSrid = 0; + + if (TryRemoveLiteralPrefix(LiteralPrefixGeography, ref text)) + { + defaultSrid = 4326; // WGS 84 + } + else if (!TryRemoveLiteralPrefix(LiteralPrefixGeometry, ref text)) + { + targetValue = default(Geometry); + return false; + } + + if (!TryRemoveQuotes(ref text)) + { + targetValue = default(Geometry); + return false; + } + + // According to the OData V4 spec, a spatial literal may include a SRID prefix: 'SRID=4326;POINT(1 2)'. + if (!text.StartsWith("SRID=", StringComparison.OrdinalIgnoreCase)) + { + // If no SRID is specified, we assume the default of 4326 for Geography and 0 for Geometry. + text = $"SRID={defaultSrid};{text}"; + } + + try + { + WKTReader wktReader = new WKTReader(NtsGeometryServices.Instance); + targetValue = wktReader.Read(text); + + return true; + } + catch (ParseException ex) + { + targetValue = default(Geometry); + parsingException = new UriLiteralParsingException( + $"Failed to parse spatial literal '{text}': {ex.Message}"); + + return false; + } + } + + /// + /// Attempts to remove a literal (geometry or geography) + /// from the start of (case-insensitive). + /// + /// The expected literal prefix. + /// The text to inspect; updated in-place if the prefix is removed. + /// + /// true if the prefix was found and removed; otherwise false. + /// + /// + /// This method is tolerant to case (e.g., GeOgRaPhY'…'), matching OData literal rules. + /// + private static bool TryRemoveLiteralPrefix(string prefix, ref string text) + { + Debug.Assert(prefix != null, "prefix != null"); + + if (text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + text = text.Remove(0, prefix.Length); + return true; + } + else + { + return false; + } + } + + /// + /// Removes the leading and trailing single quotes around a literal payload and unescapes doubled quotes. + /// + /// The quoted literal payload; updated in-place on success. + /// true if quotes were successfully removed and unescaped; otherwise false. + /// + /// Validates that the string is at least two characters long, begins and ends with a single quote, + /// and that any internal quote characters are doubled (OData single-quote escaping). Returns false + /// if the quoted form is invalid. + /// + private static bool TryRemoveQuotes(ref string text) + { + Debug.Assert(text != null, "text != null"); + + if (text.Length < 2) + { + return false; + } + + char quote = text[0]; + if (quote != '\'' || text[text.Length - 1] != quote) + { + return false; + } + + string s = text.Substring(1, text.Length - 2); + int start = 0; + while (true) + { + int i = s.IndexOf(quote, start); + if (i < 0) + { + break; + } + + s = s.Remove(i, 1); + if (s.Length < i + 1 || s[i] != quote) + { + return false; + } + + start = i + 1; + } + + text = s; + return true; + } +} diff --git a/src/Microsoft.AspNetCore.OData/Abstracts/ODataServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.OData/Abstracts/ODataServiceCollectionExtensions.cs index 0dacfd5e9..6ee6a2d72 100644 --- a/src/Microsoft.AspNetCore.OData/Abstracts/ODataServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Abstracts/ODataServiceCollectionExtensions.cs @@ -82,6 +82,7 @@ public static IServiceCollection AddDefaultWebApiServices(this IServiceCollectio services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -91,6 +92,7 @@ public static IServiceCollection AddDefaultWebApiServices(this IServiceCollectio // Serializers. services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.AspNetCore.OData/Edm/DefaultODataTypeMapper.cs b/src/Microsoft.AspNetCore.OData/Edm/DefaultODataTypeMapper.cs index c4cac6daf..02b41a30c 100644 --- a/src/Microsoft.AspNetCore.OData/Edm/DefaultODataTypeMapper.cs +++ b/src/Microsoft.AspNetCore.OData/Edm/DefaultODataTypeMapper.cs @@ -1,4 +1,4 @@ -//----------------------------------------------------------------------------- +//----------------------------------------------------------------------------- // // Copyright (c) .NET Foundation and Contributors. All rights reserved. // See License.txt in the project root for license information. @@ -26,9 +26,9 @@ namespace Microsoft.AspNetCore.OData.Edm; public class DefaultODataTypeMapper : IODataTypeMapper { /// - /// Creates a static instance for the Default type mapper. + /// Creates a static instance for the default type mapper. /// - internal static DefaultODataTypeMapper Default = new DefaultODataTypeMapper(); + internal static readonly DefaultODataTypeMapper Default; #region Default_PrimitiveTypeMapping /// @@ -46,6 +46,43 @@ private static IDictionary ClrPrimitiveTypes = new Dictionary(); static DefaultODataTypeMapper() + { + Default = new DefaultODataTypeMapper(); + } + + /// + /// Initializes a new instance of . + /// + /// + /// The constructor wires up three buckets of primitive mappings: + /// - Standard primitives: 1:1 CLR-to-Edm mappings that require no runtime conversion. + /// - Spatial primitives: Microsoft.Spatial geography/geometry types to Edm spatial kinds. + /// - Non-standard primitives: CLR types that do not have an Edm counterpart and are represented + /// by another Edm primitive (for example, , + /// ). + /// Non-standard mappings are intentionally not added to reverse Edm → CLR lookup so they don't leak into the model + /// and are instead normalized at query-time. + /// + protected DefaultODataTypeMapper() + { + // Default primitive type mappings + RegisterDefaultMappings(); + // Spatial mappings + RegisterSpatialMappings(); + // Non-standard mappings + RegisterNonStandardMappings(); + } + #endregion + + /// + /// Registers the standard CLR-to-Edm primitive mappings. + /// + /// + /// “Standard” means the CLR type has a direct Edm primitive equivalent and can round-trip without + /// normalization or provider-specific conversions during query translation. + /// Override to add/remove mappings or to change the defaults for a custom stack. + /// + protected virtual void RegisterDefaultMappings() { BuildReferenceTypeMapping(EdmPrimitiveTypeKind.String); BuildValueTypeMapping(EdmPrimitiveTypeKind.Boolean); @@ -64,7 +101,46 @@ static DefaultODataTypeMapper() BuildValueTypeMapping(EdmPrimitiveTypeKind.Duration); BuildValueTypeMapping(EdmPrimitiveTypeKind.Date); BuildValueTypeMapping(EdmPrimitiveTypeKind.TimeOfDay); + } + /// + /// Registers non-standard CLR primitive mappings that are represented by another Edm primitive. + /// + /// + /// Examples of non-standard CLR types and their Edm representations: + /// - + /// - , + /// - , [], + /// - + /// - + /// - + /// These mappings are used for CLR → Edm lookups. They are not registered in the reverse Edm → CLR table: + /// the query binder normalizes such values at runtime, + /// ensuring providers (e.g., EF Core) receive supported primitives. + /// + protected virtual void RegisterNonStandardMappings() + { + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.String, isStandard: false); + BuildValueTypeMapping(EdmPrimitiveTypeKind.Int32, isStandard: false); + BuildValueTypeMapping(EdmPrimitiveTypeKind.Int64, isStandard: false); + BuildValueTypeMapping(EdmPrimitiveTypeKind.Int64, isStandard: false); + BuildReferenceTypeMapping(EdmPrimitiveTypeKind.String, isStandard: false); + BuildValueTypeMapping(EdmPrimitiveTypeKind.String, isStandard: false); + BuildValueTypeMapping(EdmPrimitiveTypeKind.DateTimeOffset, isStandard: false); + BuildValueTypeMapping(EdmPrimitiveTypeKind.Date, isStandard: false); + BuildValueTypeMapping(EdmPrimitiveTypeKind.TimeOfDay, isStandard: false); + } + + /// + /// Registers geography/geometry CLR types to Edm spatial primitive kinds. + /// + /// + /// This mapping assumes Microsoft.Spatial abstractions are used for Edm spatial types on the wire. + /// If your application introduces an alternate spatial stack, override this method to register the desired + /// CLR spatial types to Edm geography/geometry kinds. + /// + protected virtual void RegisterSpatialMappings() + { BuildReferenceTypeMapping(EdmPrimitiveTypeKind.Geography); BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeographyPoint); BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeographyLineString); @@ -81,19 +157,7 @@ static DefaultODataTypeMapper() BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryMultiLineString); BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryMultiPoint); BuildReferenceTypeMapping(EdmPrimitiveTypeKind.GeometryMultiPolygon); - - // non-standard mappings - BuildReferenceTypeMapping(EdmPrimitiveTypeKind.String, isStandard: false); - BuildValueTypeMapping(EdmPrimitiveTypeKind.Int32, isStandard: false); - BuildValueTypeMapping(EdmPrimitiveTypeKind.Int64, isStandard: false); - BuildValueTypeMapping(EdmPrimitiveTypeKind.Int64, isStandard: false); - BuildReferenceTypeMapping(EdmPrimitiveTypeKind.String, isStandard: false); - BuildValueTypeMapping(EdmPrimitiveTypeKind.String, isStandard: false); - BuildValueTypeMapping(EdmPrimitiveTypeKind.DateTimeOffset, isStandard: false); - BuildValueTypeMapping(EdmPrimitiveTypeKind.Date, isStandard: false); - BuildValueTypeMapping(EdmPrimitiveTypeKind.TimeOfDay, isStandard: false); } - #endregion #region IODataTypeMapper.GetPrimitiveType /// @@ -395,7 +459,18 @@ private static Type ExtractGenericInterface(Type queryType, Type interfaceType) private static IEnumerable GetMatchingTypes(string edmFullName, IAssemblyResolver assembliesResolver) => TypeHelper.GetLoadedTypes(assembliesResolver).Where(t => t.IsPublic && t.EdmFullName() == edmFullName); - private static void BuildTypeMapping(EdmPrimitiveTypeKind primitiveKind, bool isStandard) + + /// + /// Adds a CLR-to-EDM primitive mapping entry. + /// + /// The CLR type being mapped. + /// The Edm primitive kind to map to. + /// + /// Whether this is a “standard” mapping (true) or a “non-standard” normalization mapping (false). + /// Standard mappings are also registered for reverse Edm → CLR lookup; non-standard mappings are not, + /// so the CLR non-standard type will not appear in Edm → CLR resolutions and will be normalized at bind-time. + /// + protected virtual void BuildTypeMapping(EdmPrimitiveTypeKind primitiveKind, bool isStandard) { Type type = typeof(T); bool isNullable = type.IsNullable(); @@ -409,7 +484,7 @@ private static void BuildTypeMapping(EdmPrimitiveTypeKind primitiveKind, bool { // for nullable, for example System.String, we don't have non-nullable string. // so, let's save it for both. - // And since we make the order un-changable, it means 'nullable' coming first. + // And since we make the order un-changeable, it means 'nullable' coming first. EdmPrimitiveTypes[primitiveType] = (type, type); } else @@ -427,7 +502,20 @@ private static void BuildTypeMapping(EdmPrimitiveTypeKind primitiveKind, bool } } - private static void BuildValueTypeMapping(EdmPrimitiveTypeKind primitiveKind, bool isStandard = true) + /// + /// Adds a value-type CLR → Edm mapping for both nullable and non-nullable variants. + /// + /// The non-nullable CLR value type (struct). + /// The Edm primitive kind to map to. + /// + /// Whether this is a “standard” mapping (true) or a “non-standard” normalization mapping (false). + /// See for behavior differences. + /// + /// + /// Ordering matters: nullable is registered first so the reverse Edm → CLR cache can correctly store both + /// nullable and non-nullable standard targets. + /// + protected virtual void BuildValueTypeMapping(EdmPrimitiveTypeKind primitiveKind, bool isStandard = true) where T : struct { // Do not change the order for the nullable or non-nullable. Put nullable ahead of non-nullable. @@ -437,7 +525,16 @@ private static void BuildValueTypeMapping(EdmPrimitiveTypeKind primitiveKind, BuildTypeMapping(primitiveKind, isStandard); } - private static void BuildReferenceTypeMapping(EdmPrimitiveTypeKind primitiveKind, bool isStandard = true) + /// + /// Adds a reference-type CLR → Edm mapping. + /// + /// The CLR reference type (class). + /// The Edm primitive kind to map to. + /// + /// Whether this is a “standard” mapping (true) or a “non-standard” normalization mapping (false). + /// See for behavior differences. + /// + protected virtual void BuildReferenceTypeMapping(EdmPrimitiveTypeKind primitiveKind, bool isStandard = true) where T : class { BuildTypeMapping(primitiveKind, isStandard); diff --git a/src/Microsoft.AspNetCore.OData/Edm/EdmClrTypeMapExtensions.cs b/src/Microsoft.AspNetCore.OData/Edm/EdmClrTypeMapExtensions.cs index 85a9f6704..f77f39029 100644 --- a/src/Microsoft.AspNetCore.OData/Edm/EdmClrTypeMapExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Edm/EdmClrTypeMapExtensions.cs @@ -20,16 +20,6 @@ namespace Microsoft.AspNetCore.OData.Edm; /// internal static class EdmClrTypeMapExtensions { - /// - /// Gets the corresponding Edm primitive type for a given type. - /// - /// The given CLR type. - /// Null or the Edm primitive type. - public static IEdmPrimitiveTypeReference GetEdmPrimitiveTypeReference(this Type clrType) - { - return DefaultODataTypeMapper.Default.GetEdmPrimitiveType(clrType); - } - /// /// Gets the corresponding Edm primitive type for a given type. /// diff --git a/src/Microsoft.AspNetCore.OData/Edm/EdmModelExtensions.cs b/src/Microsoft.AspNetCore.OData/Edm/EdmModelExtensions.cs index 176007a40..2228213ba 100644 --- a/src/Microsoft.AspNetCore.OData/Edm/EdmModelExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Edm/EdmModelExtensions.cs @@ -483,11 +483,31 @@ internal static bool IsResourceOrCollectionResource(this IEdmTypeReference edmTy } /// - /// Tests type reference is enum or collection enum + /// Checks if type reference is a primitive or a collection of primitives. /// - /// - /// - public static bool IsEnumOrCollectionEnum(this IEdmTypeReference edmType) + /// The EDM type reference to check. + /// true if the EDM type reference is a primitive or a collection of primitives; otherwise, false. + public static bool IsPrimitiveOrCollectionOfPrimitive(this IEdmTypeReference edmType) + { + if (edmType.IsPrimitive()) + { + return true; + } + + if (edmType.IsCollection()) + { + return IsPrimitiveOrCollectionOfPrimitive(edmType.AsCollection().ElementType()); + } + + return false; + } + + /// + /// Checks if type reference is an enum or a collection of enums. + /// + /// The EDM type reference to check. + /// true if the EDM type reference is an enum or a collection of enums; otherwise, false. + public static bool IsEnumOrCollectionOfEnum(this IEdmTypeReference edmType) { if (edmType.IsEnum()) { @@ -496,7 +516,7 @@ public static bool IsEnumOrCollectionEnum(this IEdmTypeReference edmType) if (edmType.IsCollection()) { - return IsEnumOrCollectionEnum(edmType.AsCollection().ElementType()); + return IsEnumOrCollectionOfEnum(edmType.AsCollection().ElementType()); } return false; diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/DeserializationHelper.cs b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/DeserializationHelper.cs index 9210f7d26..3d420208b 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/DeserializationHelper.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/DeserializationHelper.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.OData.Formatter.Value; using Microsoft.OData; using Microsoft.OData.Edm; +using Microsoft.Spatial; namespace Microsoft.AspNetCore.OData.Formatter.Deserialization; @@ -310,6 +311,12 @@ internal static object ConvertValue(object oDataValue, ref IEdmTypeReference pro oDataValue = ConvertPrimitiveValue(untypedValue.RawValue); } + if (oDataValue is ISpatial spatialValue) + { + typeKind = EdmTypeKind.Primitive; + return ConvertSpatialValue(spatialValue, ref propertyType, deserializerProvider, readContext); + } + typeKind = EdmTypeKind.Primitive; return oDataValue; } @@ -454,6 +461,18 @@ private static object ConvertEnumValue(ODataEnumValue enumValue, ref IEdmTypeRef return deserializer.ReadInline(enumValue, propertyType, readContext); } + private static object ConvertSpatialValue( + ISpatial spatialValue, + ref IEdmTypeReference propertyType, + IODataDeserializerProvider deserializerProvider, + ODataDeserializerContext readContext) + { + IEdmSpatialTypeReference edmSpatialType = propertyType.AsSpatial(); + + IODataEdmTypeDeserializer deserializer = deserializerProvider.GetEdmTypeDeserializer(edmSpatialType); + return deserializer.ReadInline(spatialValue, propertyType, readContext); + } + // The same logic from ODL to get the element type name in a collection. internal static string GetCollectionElementTypeName(string typeName, bool isNested) { diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataCollectionDeserializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataCollectionDeserializer.cs index 654e424d2..9498cafa8 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataCollectionDeserializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataCollectionDeserializer.cs @@ -109,6 +109,17 @@ public sealed override object ReadInline(object item, IEdmTypeReference edmType, else { Type elementClrType = readContext.Model.GetClrType(elementType); + + if (elementType.IsSpatial()) + { + // TODO: A better way? + foreach (object collectionItem in result) + { + elementClrType = collectionItem.GetType(); + break; + } + } + IEnumerable castedResult = _castMethodInfo.MakeGenericMethod(elementClrType).Invoke(null, new object[] { result }) as IEnumerable; return castedResult; } @@ -145,7 +156,7 @@ public virtual IEnumerable ReadCollectionValue(ODataCollectionValue collectionVa foreach (object item in collectionValue.Items) { - if (elementType.IsPrimitive()) + if (elementType.IsPrimitive() && !elementType.IsSpatial()) { yield return item; } diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataDeserializerProvider.cs b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataDeserializerProvider.cs index 9a64f47d0..15794892c 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataDeserializerProvider.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataDeserializerProvider.cs @@ -52,6 +52,11 @@ public virtual IODataEdmTypeDeserializer GetEdmTypeDeserializer(IEdmTypeReferenc return _serviceProvider.GetRequiredService(); case EdmTypeKind.Primitive: + if (edmType.IsSpatial()) + { + return _serviceProvider.GetRequiredService(); + } + return _serviceProvider.GetRequiredService(); case EdmTypeKind.Collection: diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataPrimitiveDeserializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataPrimitiveDeserializer.cs index b352fecfd..06dbaa870 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataPrimitiveDeserializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataPrimitiveDeserializer.cs @@ -48,7 +48,7 @@ public override async Task ReadAsync(ODataMessageReader messageReader, T } /// - public sealed override object ReadInline(object item, IEdmTypeReference edmType, ODataDeserializerContext readContext) + public override object ReadInline(object item, IEdmTypeReference edmType, ODataDeserializerContext readContext) { if (item == null) { diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataSpatialDeserializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataSpatialDeserializer.cs new file mode 100644 index 000000000..718bab86c --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataSpatialDeserializer.cs @@ -0,0 +1,56 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.Spatial; + +namespace Microsoft.AspNetCore.OData.Formatter.Deserialization; + +// TODO: Add to declared API when finalized +/// +/// Represents an that can read OData spatial types. +/// +public class ODataSpatialDeserializer : ODataPrimitiveDeserializer +{ + /// + /// Initializes a new instance of the class. + /// + public ODataSpatialDeserializer() + : base() + { + } + + /// + public override object ReadInline(object item, IEdmTypeReference edmType, ODataDeserializerContext readContext) + { + if (item == null) + { + return null; + } + + if (readContext == null) + { + throw Error.ArgumentNull(nameof(readContext)); + } + + ODataProperty property = item as ODataProperty; + if (property != null) + { + return base.ReadInline(property, edmType, readContext); + } + + if (!(item is ISpatial)) + { + // TODO: Use resource manager for error messages + throw new ArgumentException($"The item must be of type ISpatial, but was '{item.GetType().FullName}'.", nameof(item)); + } + + return item; + } +} diff --git a/src/Microsoft.AspNetCore.OData/Formatter/ODataMessageWrapperHelper.cs b/src/Microsoft.AspNetCore.OData/Formatter/ODataMessageWrapperHelper.cs index 600052f89..4552bd8ae 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/ODataMessageWrapperHelper.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/ODataMessageWrapperHelper.cs @@ -37,7 +37,7 @@ internal static ODataMessageWrapper Create(Stream stream, IHeaderDictionary head { return new ODataMessageWrapper( stream, - headers.ToDictionary(kvp => kvp.Key, kvp => string.Join(";", kvp.Value)), + headers.ToDictionary(kvp => kvp.Key, kvp => string.Join(";", kvp.Value.ToArray())), contentIdMapping); } } diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataCollectionSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataCollectionSerializer.cs index 5ec676771..6f27365e5 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataCollectionSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataCollectionSerializer.cs @@ -13,10 +13,13 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.OData.Abstracts; +using Microsoft.AspNetCore.OData.Edm; using Microsoft.AspNetCore.OData.Extensions; using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing; using Microsoft.OData; using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; namespace Microsoft.AspNetCore.OData.Formatter.Serialization; @@ -48,8 +51,16 @@ public override async Task WriteObjectAsync(object graph, Type type, ODataMessag throw Error.ArgumentNull(nameof(writeContext)); } - IEdmTypeReference collectionType = writeContext.GetEdmType(graph, type); - Contract.Assert(collectionType != null); + IEdmTypeReference propertyType = null; + if (writeContext.Path != null && writeContext.Path.LastSegment is PropertySegment propertySegment) + { + propertyType = writeContext.Path.EdmType(); + } + + // TODO: What strategy can we use to map NTS spatial values to both Geometry and Geography EDM types? + // Or is it sufficient to rely on the property's EDM type in the case of spatial values? + // If the property type is spatial, we use it as the collection type. + IEdmTypeReference collectionType = (propertyType != null && propertyType.IsSpatial()) ? propertyType : writeContext.GetEdmType(graph, type); IEdmTypeReference elementType = GetElementType(collectionType); ODataCollectionWriter writer = await messageWriter.CreateODataCollectionWriterAsync(elementType) @@ -173,8 +184,16 @@ public virtual ODataCollectionValue CreateODataCollectionValue(IEnumerable enume throw new SerializationException(SRResources.NullElementInCollection); } - IEdmTypeReference actualType = writeContext.GetEdmType(item, item.GetType()); - Contract.Assert(actualType != null); + IEdmTypeReference actualType; + if (!elementType.IsPrimitiveOrCollectionOfPrimitive() && !elementType.IsEnumOrCollectionOfEnum()) + { + actualType = writeContext.GetEdmType(item, item.GetType()); + Contract.Assert(actualType != null); + } + else + { + actualType = elementType; + } itemSerializer = itemSerializer ?? SerializerProvider.GetEdmTypeSerializer(actualType); if (itemSerializer == null) diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataPrimitiveSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataPrimitiveSerializer.cs index 1f6000bbe..0367abe34 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataPrimitiveSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataPrimitiveSerializer.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.OData.Edm; using Microsoft.OData; using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; namespace Microsoft.AspNetCore.OData.Formatter.Serialization; @@ -47,7 +48,16 @@ public override async Task WriteObjectAsync(object graph, Type type, ODataMessag throw Error.Argument("writeContext", SRResources.RootElementNameMissing, typeof(ODataSerializerContext).Name); } - IEdmTypeReference edmType = writeContext.GetEdmType(graph, type); + IEdmTypeReference propertyType = null; + if (writeContext.Path != null && writeContext.Path.LastSegment is PropertySegment propertySegment) + { + propertyType = writeContext.Path.EdmType(); + } + + // TODO: What strategy can we use to map NTS spatial values to both Geometry and Geography EDM types? + // Or is it sufficient to rely on the property's EDM type in the case of spatial values? + // If the property type is spatial, we use it as the collection type. + IEdmTypeReference edmType = (propertyType != null && propertyType.IsSpatial()) ? propertyType : writeContext.GetEdmType(graph, type); Contract.Assert(edmType != null); await messageWriter.WritePropertyAsync(this.CreateProperty(graph, edmType, writeContext.RootElementName, writeContext)).ConfigureAwait(false); @@ -83,7 +93,8 @@ public virtual ODataPrimitiveValue CreateODataPrimitiveValue(object graph, IEdmP return CreatePrimitive(graph, primitiveType, writeContext); } - internal static void AddTypeNameAnnotationAsNeeded(ODataPrimitiveValue primitive, IEdmPrimitiveTypeReference primitiveType, + // TODO: Add method to public API and provide missing XML comments. + public static void AddTypeNameAnnotationAsNeeded(ODataPrimitiveValue primitive, IEdmPrimitiveTypeReference primitiveType, ODataMetadataLevel metadataLevel) { // ODataLib normally has the caller decide whether or not to serialize properties by leaving properties diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs index 39b7b4152..bbdd7ae57 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs @@ -1645,7 +1645,7 @@ public virtual ODataProperty CreateStructuralProperty(IEdmStructuralProperty str IEdmTypeReference propertyType = structuralProperty.Type; if (propertyValue != null) { - if (!propertyType.IsPrimitive() && !propertyType.IsEnum()) + if (!propertyType.IsPrimitiveOrCollectionOfPrimitive() && !propertyType.IsEnumOrCollectionOfEnum()) { IEdmTypeReference actualType = writeContext.GetEdmType(propertyValue, propertyValue.GetType()); if (propertyType != null && propertyType != actualType) diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerProvider.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerProvider.cs index 32ec696b7..78cefa1f2 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerProvider.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSerializerProvider.cs @@ -52,6 +52,11 @@ public virtual IODataEdmTypeSerializer GetEdmTypeSerializer(IEdmTypeReference ed return _serviceProvider.GetRequiredService(); case EdmTypeKind.Primitive: + if (edmType.IsSpatial()) + { + return _serviceProvider.GetRequiredService(); + } + return _serviceProvider.GetRequiredService(); case EdmTypeKind.Collection: diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSpatialSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSpatialSerializer.cs new file mode 100644 index 000000000..dfef93c1d --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataSpatialSerializer.cs @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Diagnostics.Contracts; +using System.Threading.Tasks; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.Spatial; + +namespace Microsoft.AspNetCore.OData.Formatter.Serialization; + +// TODO: Add to declared API when finalized +/// +/// Represents an for serializing spatial types. +/// +public class ODataSpatialSerializer : ODataPrimitiveSerializer +{ + /// + /// Initializes a new instance of . + /// + public ODataSpatialSerializer() + : base() + { + } +} diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.csproj b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.csproj index 507f2d72c..e019595ce 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.csproj +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.csproj @@ -1,7 +1,7 @@ - + - net8.0 + net10.0 Microsoft.AspNetCore.OData $(OutputPath)$(AssemblyName).xml true diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index d3eec23dc..a3a8a0ef2 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -2095,7 +2095,7 @@ - Creates a static instance for the Default type mapper. + Creates a static instance for the default type mapper. @@ -2110,6 +2110,58 @@ Item2 --> nullable + + + Initializes a new instance of . + + + The constructor wires up three buckets of primitive mappings: + - Standard primitives: 1:1 CLR-to-Edm mappings that require no runtime conversion. + - Spatial primitives: Microsoft.Spatial geography/geometry types to Edm spatial kinds. + - Non-standard primitives: CLR types that do not have an Edm counterpart and are represented + by another Edm primitive (for example, , + ). + Non-standard mappings are intentionally not added to reverse Edm → CLR lookup so they don't leak into the model + and are instead normalized at query-time. + + + + + Registers the standard CLR-to-Edm primitive mappings. + + + “Standard” means the CLR type has a direct Edm primitive equivalent and can round-trip without + normalization or provider-specific conversions during query translation. + Override to add/remove mappings or to change the defaults for a custom stack. + + + + + Registers non-standard CLR primitive mappings that are represented by another Edm primitive. + + + Examples of non-standard CLR types and their Edm representations: + - + - , + - , [], + - + - + - + These mappings are used for CLR → Edm lookups. They are not registered in the reverse Edm → CLR table: + the query binder normalizes such values at runtime, + ensuring providers (e.g., EF Core) receive supported primitives. + + + + + Registers geography/geometry CLR types to Edm spatial primitive kinds. + + + This mapping assumes Microsoft.Spatial abstractions are used for Edm spatial types on the wire. + If your application introduces an alternate spatial stack, override this method to register the desired + CLR spatial types to Edm geography/geometry kinds. + + Gets the corresponding Edm primitive type for a given type. @@ -2157,17 +2209,48 @@ The assembly resolver. Null or the CLR type. - + - The extensions used to map between C# types and Edm types. + Adds a CLR-to-EDM primitive mapping entry. + The CLR type being mapped. + The Edm primitive kind to map to. + + Whether this is a “standard” mapping (true) or a “non-standard” normalization mapping (false). + Standard mappings are also registered for reverse Edm → CLR lookup; non-standard mappings are not, + so the CLR non-standard type will not appear in Edm → CLR resolutions and will be normalized at bind-time. + - + - Gets the corresponding Edm primitive type for a given type. + Adds a value-type CLR → Edm mapping for both nullable and non-nullable variants. + + The non-nullable CLR value type (struct). + The Edm primitive kind to map to. + + Whether this is a “standard” mapping (true) or a “non-standard” normalization mapping (false). + See for behavior differences. + + + Ordering matters: nullable is registered first so the reverse Edm → CLR cache can correctly store both + nullable and non-nullable standard targets. + + + + + Adds a reference-type CLR → Edm mapping. + + The CLR reference type (class). + The Edm primitive kind to map to. + + Whether this is a “standard” mapping (true) or a “non-standard” normalization mapping (false). + See for behavior differences. + + + + + The extensions used to map between C# types and Edm types. - The given CLR type. - Null or the Edm primitive type. @@ -2450,12 +2533,19 @@ Enable case insensitive Null or the found navigation source. - + - Tests type reference is enum or collection enum + Checks if type reference is a primitive or a collection of primitives. - - + The EDM type reference to check. + true if the EDM type reference is a primitive or a collection of primitives; otherwise, false. + + + + Checks if type reference is an enum or a collection of enums. + + The EDM type reference to check. + true if the EDM type reference is an enum or a collection of enums; otherwise, false. @@ -3865,6 +3955,19 @@ The deserializer context. The deserialized resource set object. + + + Represents an that can read OData spatial types. + + + + + Initializes a new instance of the class. + + + + + Get the expected payload type of an OData path. @@ -5449,6 +5552,16 @@ + + + Represents an for serializing spatial types. + + + + + Initializes a new instance of . + + Describes the set of structural properties and navigation properties and actions to select and navigation properties to expand while @@ -7273,24 +7386,26 @@ OData UriFunctions helper. - + This is a shortcut of adding the custom FunctionSignature through 'CustomUriFunctions' class and binding the function name to it's MethodInfo through 'UriFunctionsBinder' class. See these classes documentations. In case of an exception, both operations(adding the signature and binding the function) will be undone. + The to which the function signature will be added. The uri function name that appears in the OData request uri. The new custom function signature. The MethodInfo to bind the given function name. Any exception thrown by 'CustomUriFunctions.AddCustomUriFunction' and 'UriFunctionBinder.BindUriFunctionName' methods. - + This is a shortcut of removing the FunctionSignature through 'CustomUriFunctions' class and unbinding the function name from it's MethodInfo through 'UriFunctionsBinder' class. See these classes documentations. + The to which the function signature will be removed. The uri function name that appears in the OData request uri. The new custom function signature. The MethodInfo to bind the given function name. diff --git a/src/Microsoft.AspNetCore.OData/ODataUriFunctions.cs b/src/Microsoft.AspNetCore.OData/ODataUriFunctions.cs index a17abf81f..2ad4f430a 100644 --- a/src/Microsoft.AspNetCore.OData/ODataUriFunctions.cs +++ b/src/Microsoft.AspNetCore.OData/ODataUriFunctions.cs @@ -8,6 +8,7 @@ using System; using System.Reflection; using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.OData.Edm; using Microsoft.OData.UriParser; namespace Microsoft.AspNetCore.OData; @@ -23,17 +24,18 @@ public static class ODataUriFunctions /// See these classes documentations. /// In case of an exception, both operations(adding the signature and binding the function) will be undone. /// + /// The to which the function signature will be added. /// The uri function name that appears in the OData request uri. /// The new custom function signature. /// The MethodInfo to bind the given function name. /// Any exception thrown by 'CustomUriFunctions.AddCustomUriFunction' and 'UriFunctionBinder.BindUriFunctionName' methods. - public static void AddCustomUriFunction(string functionName, + public static void AddCustomUriFunction(IEdmModel model, string functionName, FunctionSignatureWithReturnType functionSignature, MethodInfo methodInfo) { try { // Add to OData.Libs function signature - CustomUriFunctions.AddCustomUriFunction(functionName, functionSignature); + model.AddCustomUriFunction(functionName, functionSignature); // Bind the method to it's MethoInfo UriFunctionsBinder.BindUriFunctionName(functionName, methodInfo); @@ -41,7 +43,7 @@ public static void AddCustomUriFunction(string functionName, catch { // Clear in case of exception - RemoveCustomUriFunction(functionName, functionSignature, methodInfo); + RemoveCustomUriFunction(model, functionName, functionSignature, methodInfo); throw; } } @@ -51,16 +53,17 @@ public static void AddCustomUriFunction(string functionName, /// unbinding the function name from it's MethodInfo through 'UriFunctionsBinder' class. /// See these classes documentations. /// + /// The to which the function signature will be removed. /// The uri function name that appears in the OData request uri. /// The new custom function signature. /// The MethodInfo to bind the given function name. /// Any exception thrown by 'CustomUriFunctions.RemoveCustomUriFunction' and 'UriFunctionsBinder.UnbindUriFunctionName' methods. /// 'True' if the function signature has successfully removed and unbounded. 'False' otherwise. - public static bool RemoveCustomUriFunction(string functionName, + public static bool RemoveCustomUriFunction(IEdmModel model, string functionName, FunctionSignatureWithReturnType functionSignature, MethodInfo methodInfo) { return - CustomUriFunctions.RemoveCustomUriFunction(functionName, functionSignature) && + model.RemoveCustomUriFunction(functionName, functionSignature) && UriFunctionsBinder.UnbindUriFunctionName(functionName, methodInfo); } } diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index 2a007b928..3904bc7e5 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -86,9 +86,16 @@ static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithO static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.WithODataVersion(this TBuilder builder, Microsoft.OData.ODataVersion version) -> TBuilder static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddOData(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddOData(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action setupAction) -> Microsoft.Extensions.DependencyInjection.IServiceCollection +static Microsoft.AspNetCore.OData.ODataUriFunctions.AddCustomUriFunction(Microsoft.OData.Edm.IEdmModel model, string functionName, Microsoft.OData.UriParser.FunctionSignatureWithReturnType functionSignature, System.Reflection.MethodInfo methodInfo) -> void static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask> static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.PopulateMetadata(System.Reflection.ParameterInfo parameter, Microsoft.AspNetCore.Builder.EndpointBuilder builder) -> void static readonly Microsoft.AspNetCore.OData.Query.Wrapper.SelectExpandWrapperConverter.MapperProvider -> System.Func +virtual Microsoft.AspNetCore.OData.Edm.DefaultODataTypeMapper.BuildReferenceTypeMapping(Microsoft.OData.Edm.EdmPrimitiveTypeKind primitiveKind, bool isStandard = true) -> void +virtual Microsoft.AspNetCore.OData.Edm.DefaultODataTypeMapper.BuildTypeMapping(Microsoft.OData.Edm.EdmPrimitiveTypeKind primitiveKind, bool isStandard) -> void +virtual Microsoft.AspNetCore.OData.Edm.DefaultODataTypeMapper.BuildValueTypeMapping(Microsoft.OData.Edm.EdmPrimitiveTypeKind primitiveKind, bool isStandard = true) -> void +virtual Microsoft.AspNetCore.OData.Edm.DefaultODataTypeMapper.RegisterDefaultMappings() -> void +virtual Microsoft.AspNetCore.OData.Edm.DefaultODataTypeMapper.RegisterNonStandardMappings() -> void +virtual Microsoft.AspNetCore.OData.Edm.DefaultODataTypeMapper.RegisterSpatialMappings() -> void virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.ApplyQuery(object entity, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) -> object virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.ApplyQuery(System.Linq.IQueryable queryable, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions, Microsoft.AspNetCore.OData.Query.ODataQuerySettings querySettings) -> System.Linq.IQueryable virtual Microsoft.AspNetCore.OData.Query.ODataQueryEndpointFilter.CreateAndValidateQueryOptions(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.OData.Query.ODataQueryContext queryContext) -> Microsoft.AspNetCore.OData.Query.ODataQueryOptions diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs index fcb47a5e0..c6485cc80 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs @@ -24,6 +24,7 @@ using Microsoft.OData.Edm; using Microsoft.OData.ModelBuilder; using Microsoft.OData.UriParser; +using Microsoft.Spatial; namespace Microsoft.AspNetCore.OData.Query.Expressions; @@ -1381,6 +1382,11 @@ internal static Expression ConvertNonStandardPrimitives(Expression source, Query convertedExpression = Expression.Call(source, "ToArray", typeArguments: null, arguments: null); } #endif + else if (typeof(Geometry).IsAssignableFrom(conversionType) || typeof(Geography).IsAssignableFrom(conversionType)) + { + convertedExpression = source; + } + break; default: diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Microsoft.AspNetCore.OData.NetTopologySuite.Tests.csproj b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Microsoft.AspNetCore.OData.NetTopologySuite.Tests.csproj new file mode 100644 index 000000000..644c8b4ab --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Microsoft.AspNetCore.OData.NetTopologySuite.Tests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + false + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Expressions/ExtendedFilterBinder.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Expressions/ExtendedFilterBinder.cs new file mode 100644 index 000000000..7fde7edfa --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Expressions/ExtendedFilterBinder.cs @@ -0,0 +1,143 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.OData.UriParser; +using NtsGeometry = NetTopologySuite.Geometries.Geometry; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Expressions; + +public class ExtendedFilterBinder : FilterBinder +{ + private const string GeoDistanceFunctionName = "geo.distance"; + private const string GeoLengthFunctionName = "geo.length"; + private const string GeoIntersectsFunctionName = "geo.intersects"; + private static readonly MethodInfo DistanceMethod = typeof(NtsGeometry).GetMethod("Distance", [typeof(NtsGeometry)]); + private static readonly PropertyInfo LengthProp = typeof(NtsGeometry).GetProperty("Length"); + private static readonly MethodInfo IntersectsMethod = typeof(NtsGeometry).GetMethod("Intersects", [typeof(NtsGeometry)]); + + public override Expression BindSingleValueFunctionCallNode(SingleValueFunctionCallNode node, QueryBinderContext context) + { + if (node.Name == GeoDistanceFunctionName) + { + return BindGeoDistance(node, context); + } + else if (node.Name == GeoLengthFunctionName) + { + return BindGeoLength(node, context); + } + else if (node.Name == GeoIntersectsFunctionName) + { + return BindGeoIntersects(node, context); + } + + return base.BindSingleValueFunctionCallNode(node, context); + } + + public override Expression BindConstantNode(ConstantNode constantNode, QueryBinderContext context) + { + object value = constantNode.Value; + // ODL doesn't know NTS types => TypeReference can be null for spatial literals. + if (constantNode.TypeReference == null && value != null && typeof(NtsGeometry).IsAssignableFrom(value.GetType())) + { + Type constantType = value.GetType(); + + if (context.QuerySettings.EnableConstantParameterization) + { + return LinqParameterizer.Parameterize(constantType, value); + } + else + { + return Expression.Constant(value, constantType); + } + } + + return base.BindConstantNode(constantNode, context); + } + + private Expression BindGeoDistance(SingleValueFunctionCallNode node, QueryBinderContext context) + { + // Expect exactly two parameters: (geomA, geomB) + var arguments = BindArguments(node.Parameters, context); + + if (arguments == null || arguments.Length != 2) + { + throw new NotSupportedException($"The function '{GeoDistanceFunctionName}' must have exactly two parameters."); + } + + Expression left = arguments[0]; + Expression right = arguments[1]; + + // Emit left.Distance(right) + return Expression.Call(left, DistanceMethod, right); + } + + private Expression BindGeoLength(SingleValueFunctionCallNode node, QueryBinderContext context) + { + // Expect exactly one parameter: (geom) + var arguments = BindArguments(node.Parameters, context); + if (arguments == null || arguments.Length != 1) + { + throw new NotSupportedException($"The function '{GeoLengthFunctionName}' must have exactly one parameter."); + } + + Expression geom = arguments[0]; + + // Emit geom.Length + return Expression.Property(geom, LengthProp); + } + + private Expression BindGeoIntersects(SingleValueFunctionCallNode node, QueryBinderContext context) + { + // Expect exactly two parameters: (geomA, geomB) + var arguments = BindArguments(node.Parameters, context); + if (arguments == null || arguments.Length != 2) + { + throw new NotSupportedException($"The function '{GeoIntersectsFunctionName}' must have exactly two parameters."); + } + + Expression left = arguments[0]; + Expression right = arguments[1]; + + // Emit left.Intersects(right) + return Expression.Call(left, IntersectsMethod, right); + } + + // Lightweight parameterizer for EF Core query translation + private static class LinqParameterizer + { + private static readonly System.Collections.Concurrent.ConcurrentDictionary Ctor, PropertyInfo Prop)> Cache + = new(); + + public static Expression Parameterize(Type type, object value) + { + var entry = Cache.GetOrAdd(type, Build); + var wrapper = entry.Ctor(value); + // () => new Wrapper(value).TypedProperty + return Expression.Property(Expression.Constant(wrapper), entry.Prop); + } + + private static (Func Ctor, PropertyInfo Prop) Build(Type t) + { + var wrapperType = typeof(Wrapper<>).MakeGenericType(t); + var ctorInfo = wrapperType.GetConstructor(new[] { t })!; + var propInfo = wrapperType.GetProperty(nameof(Wrapper.TypedProperty))!; + // Fast late-bound ctor: object -> object (wrapper instance) + Func ctor = (object v) => ctorInfo.Invoke(new[] { v }); + return (ctor, propInfo); + } + + // Wrapper used to expose a strongly-typed property for EF parameterization + private sealed class Wrapper + { + public Wrapper(T value) => TypedProperty = value; + public T TypedProperty { get; } + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterController.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterController.cs new file mode 100644 index 000000000..f8126021b --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterController.cs @@ -0,0 +1,98 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using Microsoft.EntityFrameworkCore; +using NetTopologySuite; +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geography; + +public class SitesController : ODataController +{ + private readonly GeographyDollarFilterDbContext db; + + public SitesController(GeographyDollarFilterDbContext db) + { + this.db = db; + this.SeedDatabase(); + } + + [EnableQuery] + public ActionResult> Get() + { + return db.Sites; + } + + #region Helper Methods + + private void SeedDatabase() + { + this.db.Database.EnsureCreated(); + + if (!this.db.Sites.Any()) + { + var geographyFactory = NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326); + + // Open the connection manually + var connection = this.db.Database.GetDbConnection(); + connection.Open(); + + using var transaction = connection.BeginTransaction(); + this.db.Database.UseTransaction(transaction); + + try + { + this.db.Database.ExecuteSqlRaw("SET IDENTITY_INSERT dbo.Sites ON"); + + this.db.Sites.AddRange( + new Site + { + Id = 1, + Location = geographyFactory.CreatePoint(new Coordinate(-122.123889, 47.669444)), + Route = geographyFactory.CreateLineString( + [ + new Coordinate(-122.20, 47.65), + new Coordinate(-122.18, 47.66), + new Coordinate(-122.16, 47.67) + ]) + }, + new Site + { + Id = 2, + Location = geographyFactory.CreatePoint(new Coordinate(-122.335167, 47.608013)), + Route = geographyFactory.CreateLineString( + [ + new Coordinate(-122.10, 47.60), + new Coordinate(-122.08, 47.62), + new Coordinate(-122.06, 47.62) + ]) + } + ); + + this.db.SaveChanges(); + + this.db.Database.ExecuteSqlRaw("SET IDENTITY_INSERT dbo.Sites OFF"); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + finally + { + connection.Close(); + } + } + } + + #endregion Helper Methods +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterDataModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterDataModel.cs new file mode 100644 index 000000000..87cfe4822 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterDataModel.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Attributes; +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geography +{ + public class Site + { + public int Id { get; set; } + [Geography] + public Point Location { get; set; } + [Geography] + public LineString Route { get; set; } + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterDbContext.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterDbContext.cs new file mode 100644 index 000000000..ee4d7dc1c --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterDbContext.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.EntityFrameworkCore; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geography; + +public class GeographyDollarFilterDbContext : DbContext +{ + public GeographyDollarFilterDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.Property(x => x.Location).HasColumnType("geography"); // Default is geography + e.Property(x => x.Route).HasColumnType("geography"); // Default is geography + }); + } + + public DbSet Sites { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterEdmModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterEdmModel.cs new file mode 100644 index 000000000..df3cafd20 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterEdmModel.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geography; + +public class GeographyDollarFilterEdmModel +{ + public static IEdmModel GetEdmModel() + { + var modelBuilder = new ODataConventionModelBuilder() + .UseNetTopologySuite(); + + modelBuilder.EntitySet("Sites"); + + var model = modelBuilder.GetEdmModel() + .UseNetTopologySuite(); + + return model; + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterTests.cs new file mode 100644 index 000000000..d38d0febb --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geography/GeographyDollarFilterTests.cs @@ -0,0 +1,247 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +using Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Expressions; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geography; + +public class GeographyDollarFilterTests : WebApiTestBase +{ + public GeographyDollarFilterTests(WebApiTestFixture fixture) : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = GeographyDollarFilterEdmModel.GetEdmModel(); + + services.ConfigureControllers( + typeof(SitesController)); + + string connectionString = @"Data Source=(LocalDb)\MSSQLLocalDB;Integrated Security=True;Initial Catalog=DollarFilterNetTopologySuiteGeographyDb;"; + services.AddDbContext( + options => options.UseSqlServer(connectionString, sqlOptions => + { + sqlOptions.UseNetTopologySuite(); + })); + + services.AddControllers().AddOData( + options => + options.EnableQueryFeatures().AddRouteComponents( + routePrefix: string.Empty, + model: model, + configureServices: (nestedServices) => + { + nestedServices.AddSingleton(); + nestedServices.AddODataNetTopologySuite(); + })); + } + + protected static void UpdateConfigure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + [Fact] + public async Task GeoDistance_Filter_With_GeographyLiteral_Returns_Single_ResultAsync() + { + // Arrange + var queryUrl = $"/Sites?$filter=geo.distance(Location,geography'SRID=4326;POINT(-122.123889 47.669444)') lt 0.05"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + //Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var valueProp = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + // value array + Assert.Equal(JsonValueKind.Array, valueProp.ValueKind); + var site1 = Assert.Single(valueProp.EnumerateArray().ToArray()); + + Assert.Equal(1, site1.GetProperty("Id").GetInt32()); + + // Location + var location = site1.GetProperty("Location"); + Assert.Equal("Point", location.GetProperty("type").GetString()); + + var locationCoords = location.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, locationCoords.Length); + Assert.Equal(-122.123889, locationCoords[0].GetDouble(), 6); + Assert.Equal(47.669444, locationCoords[1].GetDouble(), 6); + + var locationCrs = location.GetProperty("crs"); + Assert.Equal("name", locationCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", locationCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + + // Route + var route = site1.GetProperty("Route"); + Assert.Equal("LineString", route.GetProperty("type").GetString()); + + var routeCoords = route.GetProperty("coordinates").EnumerateArray().Select(a => a.EnumerateArray().ToArray()).ToArray(); + Assert.Equal(3, routeCoords.Length); + + Assert.Equal(-122.20, routeCoords[0][0].GetDouble(), 2); + Assert.Equal(47.65, routeCoords[0][1].GetDouble(), 2); + + Assert.Equal(-122.18, routeCoords[1][0].GetDouble(), 2); + Assert.Equal(47.66, routeCoords[1][1].GetDouble(), 2); + + Assert.Equal(-122.16, routeCoords[2][0].GetDouble(), 2); + Assert.Equal(47.67, routeCoords[2][1].GetDouble(), 2); + + var routeCrs = route.GetProperty("crs"); + Assert.Equal("name", routeCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", routeCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeoLength_Filter_With_GeographyLiteral_Returns_Single_ResultAsync() + { + // Arrange + var queryUrl = $"/Sites?$filter=geo.length(Route) gt 4000"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var valueProp = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + // value array + Assert.Equal(JsonValueKind.Array, valueProp.ValueKind); + var site2 = Assert.Single(valueProp.EnumerateArray().ToArray()); + + // Id + Assert.Equal(2, site2.GetProperty("Id").GetInt32()); + + // Location + var location = site2.GetProperty("Location"); + Assert.Equal("Point", location.GetProperty("type").GetString()); + + var locationCoords = location.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, locationCoords.Length); + Assert.Equal(-122.335167, locationCoords[0].GetDouble(), 6); + Assert.Equal(47.608013, locationCoords[1].GetDouble(), 6); + + var locationCrs = location.GetProperty("crs"); + Assert.Equal("name", locationCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", locationCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + + // Route + var route = site2.GetProperty("Route"); + Assert.Equal("LineString", route.GetProperty("type").GetString()); + + var routeCoords = route.GetProperty("coordinates").EnumerateArray().Select(a => a.EnumerateArray().ToArray()).ToArray(); + Assert.Equal(3, routeCoords.Length); + Assert.Equal(-122.10, routeCoords[0][0].GetDouble(), 2); + Assert.Equal(47.60, routeCoords[0][1].GetDouble(), 2); + Assert.Equal(-122.08, routeCoords[1][0].GetDouble(), 2); + Assert.Equal(47.62, routeCoords[1][1].GetDouble(), 2); + Assert.Equal(-122.06, routeCoords[2][0].GetDouble(), 2); + Assert.Equal(47.62, routeCoords[2][1].GetDouble(), 2); + + var routeCrs = route.GetProperty("crs"); + Assert.Equal("name", routeCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", routeCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeoIntersects_Filter_With_GeographyLiteral_Returns_Single_ResultAsync() + { + // Arrange + // NOTE: SQL Server's geography requires the polygon to be counter-clockwise + var queryUrl = $"/Sites?$filter=geo.intersects(Location,geography'SRID=4326;POLYGON((-122.345 47.606,-122.325 47.606,-122.325 47.610,-122.345 47.610,-122.345 47.606))')"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var valueProp = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + // value array + Assert.Equal(JsonValueKind.Array, valueProp.ValueKind); + var site2 = Assert.Single(valueProp.EnumerateArray().ToArray()); + + // Id + Assert.Equal(2, site2.GetProperty("Id").GetInt32()); + + // Location + var location = site2.GetProperty("Location"); + Assert.Equal("Point", location.GetProperty("type").GetString()); + + var locationCoords = location.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, locationCoords.Length); + Assert.Equal(-122.335167, locationCoords[0].GetDouble(), 6); + Assert.Equal(47.608013, locationCoords[1].GetDouble(), 6); + + var locationCrs = location.GetProperty("crs"); + Assert.Equal("name", locationCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", locationCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + + // Route + var route = site2.GetProperty("Route"); + Assert.Equal("LineString", route.GetProperty("type").GetString()); + + var routeCoords = route.GetProperty("coordinates").EnumerateArray().Select(a => a.EnumerateArray().ToArray()).ToArray(); + Assert.Equal(3, routeCoords.Length); + Assert.Equal(-122.10, routeCoords[0][0].GetDouble(), 2); + Assert.Equal(47.60, routeCoords[0][1].GetDouble(), 2); + Assert.Equal(-122.08, routeCoords[1][0].GetDouble(), 2); + Assert.Equal(47.62, routeCoords[1][1].GetDouble(), 2); + Assert.Equal(-122.06, routeCoords[2][0].GetDouble(), 2); + Assert.Equal(47.62, routeCoords[2][1].GetDouble(), 2); + + var routeCrs = route.GetProperty("crs"); + Assert.Equal("name", routeCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", routeCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterController.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterController.cs new file mode 100644 index 000000000..c87855b75 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterController.cs @@ -0,0 +1,99 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using Microsoft.EntityFrameworkCore; +using NetTopologySuite; +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geometry; + +public class PlantsController : ODataController +{ + private readonly GeometryDollarFilterDbContext db; + + public PlantsController(GeometryDollarFilterDbContext db) + { + this.db = db; + this.SeedDatabase(); + } + + [EnableQuery] + public ActionResult> Get() + { + return db.Plants; + } + + #region Helper Methods + + private void SeedDatabase() + { + + this.db.Database.EnsureCreated(); + + if (!this.db.Plants.Any()) + { + var geometryFactory = NtsGeometryServices.Instance.CreateGeometryFactory(srid: 0); + + // Open the connection manually + var connection = this.db.Database.GetDbConnection(); + connection.Open(); + + using var transaction = connection.BeginTransaction(); + this.db.Database.UseTransaction(transaction); + + try + { + this.db.Database.ExecuteSqlRaw("SET IDENTITY_INSERT dbo.Plants ON"); + + this.db.Plants.AddRange( + new Plant + { + Id = 1, + Location = geometryFactory.CreatePoint(new Coordinate(15, 72)), + Route = geometryFactory.CreateLineString( + [ + new Coordinate(8, 66), + new Coordinate(22, 72), + new Coordinate(36, 76) + ]) + }, + new Plant + { + Id = 2, + Location = geometryFactory.CreatePoint(new Coordinate(46, 61)), + Route = geometryFactory.CreateLineString( + [ + new Coordinate(65, 52), + new Coordinate(82, 56), + new Coordinate(90, 56) + ]) + } + ); + + this.db.SaveChanges(); + + this.db.Database.ExecuteSqlRaw("SET IDENTITY_INSERT dbo.Plants OFF"); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + finally + { + connection.Close(); + } + } + } + + #endregion Helper Methods +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterDataModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterDataModel.cs new file mode 100644 index 000000000..0d2dbd2ee --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterDataModel.cs @@ -0,0 +1,18 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geometry +{ + public class Plant + { + public int Id { get; set; } + public Point Location { get; set; } + public LineString Route { get; set; } + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterDbContext.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterDbContext.cs new file mode 100644 index 000000000..d11038734 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterDbContext.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.EntityFrameworkCore; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geometry; + +public class GeometryDollarFilterDbContext : DbContext +{ + public GeometryDollarFilterDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.Property(x => x.Location).HasColumnType("geometry"); // Default is geography + e.Property(x => x.Route).HasColumnType("geometry"); // Default is geography + }); + } + + public DbSet Plants { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterEdmModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterEdmModel.cs new file mode 100644 index 000000000..156cb2033 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterEdmModel.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geometry; + +public class GeometryDollarFilterEdmModel +{ + public static IEdmModel GetEdmModel() + { + var modelBuilder = new ODataConventionModelBuilder() + .UseNetTopologySuite(); + + modelBuilder.EntitySet("Plants"); + + var model = modelBuilder.GetEdmModel() + .UseNetTopologySuite(); + + return model; + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterTests.cs new file mode 100644 index 000000000..09de69d0f --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/Geometry/GeometryDollarFilterTests.cs @@ -0,0 +1,255 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +using Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Expressions; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.Geometry; + +public class GeometryDollarFilterTests : WebApiTestBase +{ + public GeometryDollarFilterTests(WebApiTestFixture fixture) : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = GeometryDollarFilterEdmModel.GetEdmModel(); + + services.ConfigureControllers( + typeof(PlantsController)); + + string connectionString = @"Data Source=(LocalDb)\MSSQLLocalDB;Integrated Security=True;Initial Catalog=DollarFilterNetTopologySuiteGeometryDb;"; + services.AddDbContext( + options => options.UseSqlServer(connectionString, sqlOptions => + { + sqlOptions.UseNetTopologySuite(); + })); + + services.AddControllers().AddOData( + options => + options.EnableQueryFeatures().AddRouteComponents( + routePrefix: string.Empty, + model: model, + configureServices: (nestedServices) => + { + nestedServices.AddSingleton(); + nestedServices.AddODataNetTopologySuite(); + })); + } + + protected static void UpdateConfigure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + [Fact] + public async Task GeoDistance_Filter_With_GeometryLiteral_Returns_Single_ResultAsync() + { + // Arrange: SRID=0 planar point nearer Plant 1 location (15, 72) + var queryUrl = $"/Plants?$filter=geo.distance(Location,geometry'SRID=0;POINT(7 13)') lt 60"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(result); + var valueProp = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + // value array + Assert.Equal(JsonValueKind.Array, valueProp.ValueKind); + var plant1 = Assert.Single(valueProp.EnumerateArray().ToArray()); + Assert.Equal(1, plant1.GetProperty("Id").GetInt32()); + + // Location + var location = plant1.GetProperty("Location"); + Assert.Equal("Point", location.GetProperty("type").GetString()); + + var locationCoords = location.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, locationCoords.Length); + Assert.Equal(15.0, locationCoords[0].GetDouble(), 6); // X + Assert.Equal(72.0, locationCoords[1].GetDouble(), 6); // Y + + var locationCrs = location.GetProperty("crs"); + Assert.Equal("name", locationCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", locationCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + + // Route for Plant 1 (8,66) → (22,72) → (36,76) + var route = plant1.GetProperty("Route"); + Assert.Equal("LineString", route.GetProperty("type").GetString()); + + var routeCoords = route.GetProperty("coordinates").EnumerateArray() + .Select(a => a.EnumerateArray().ToArray()).ToArray(); + Assert.Equal(3, routeCoords.Length); + + Assert.Equal(8.0, routeCoords[0][0].GetDouble(), 2); + Assert.Equal(66.0, routeCoords[0][1].GetDouble(), 2); + + Assert.Equal(22.0, routeCoords[1][0].GetDouble(), 2); + Assert.Equal(72.0, routeCoords[1][1].GetDouble(), 2); + + Assert.Equal(36.0, routeCoords[2][0].GetDouble(), 2); + Assert.Equal(76.0, routeCoords[2][1].GetDouble(), 2); + + var routeCrs = route.GetProperty("crs"); + Assert.Equal("name", routeCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", routeCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeoLength_Filter_With_GeometryLiteral_Returns_Single_ResultAsync() + { + // Arrange: + // Plant 1 route length ≈ 29.8; Plant 2 ≈ 25.5 (in arbitrary planar units). + // Use a threshold that returns only Plant 1. + var queryUrl = $"/Plants?$filter=geo.length(Route) gt 28"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(result); + var valueProp = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + // value array + Assert.Equal(JsonValueKind.Array, valueProp.ValueKind); + var plant1 = Assert.Single(valueProp.EnumerateArray().ToArray()); + + // Id + Assert.Equal(1, plant1.GetProperty("Id").GetInt32()); + + // Location (15, 72) + var location = plant1.GetProperty("Location"); + Assert.Equal("Point", location.GetProperty("type").GetString()); + + var locationCoords = location.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, locationCoords.Length); + Assert.Equal(15.0, locationCoords[0].GetDouble(), 6); + Assert.Equal(72.0, locationCoords[1].GetDouble(), 6); + + var locationCrs = location.GetProperty("crs"); + Assert.Equal("name", locationCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", locationCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + + // Route for Plant 1 (8,66) → (22,72) → (36,76) + var route = plant1.GetProperty("Route"); + Assert.Equal("LineString", route.GetProperty("type").GetString()); + + var routeCoords = route.GetProperty("coordinates").EnumerateArray() + .Select(a => a.EnumerateArray().ToArray()).ToArray(); + Assert.Equal(3, routeCoords.Length); + + Assert.Equal(8.0, routeCoords[0][0].GetDouble(), 2); + Assert.Equal(66.0, routeCoords[0][1].GetDouble(), 2); + + Assert.Equal(22.0, routeCoords[1][0].GetDouble(), 2); + Assert.Equal(72.0, routeCoords[1][1].GetDouble(), 2); + + Assert.Equal(36.0, routeCoords[2][0].GetDouble(), 2); + Assert.Equal(76.0, routeCoords[2][1].GetDouble(), 2); + + var routeCrs = route.GetProperty("crs"); + Assert.Equal("name", routeCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", routeCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeoIntersects_Filter_With_GeometryLiteral_Returns_Single_ResultAsync() + { + // Arrange: + // A small counter-clockwise rectangle around Plant 2 location (46, 61). + var queryUrl = + $"/Plants?$filter=geo.intersects(Location,geometry'SRID=0;POLYGON((44 60.8,48 60.8,48 61.2,44 61.2,44 60.8))')"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(result); + var valueProp = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + // value array + Assert.Equal(JsonValueKind.Array, valueProp.ValueKind); + var plant2 = Assert.Single(valueProp.EnumerateArray().ToArray()); + + // Id + Assert.Equal(2, plant2.GetProperty("Id").GetInt32()); + + // Location (46, 61) + var location = plant2.GetProperty("Location"); + Assert.Equal("Point", location.GetProperty("type").GetString()); + + var locationCoords = location.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, locationCoords.Length); + Assert.Equal(46.0, locationCoords[0].GetDouble(), 6); + Assert.Equal(61.0, locationCoords[1].GetDouble(), 6); + + var locationCrs = location.GetProperty("crs"); + Assert.Equal("name", locationCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", locationCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + + // Route for Plant 2 (65,52) → (82,56) → (90,56) + var route = plant2.GetProperty("Route"); + Assert.Equal("LineString", route.GetProperty("type").GetString()); + + var routeCoords = route.GetProperty("coordinates").EnumerateArray() + .Select(a => a.EnumerateArray().ToArray()).ToArray(); + Assert.Equal(3, routeCoords.Length); + + Assert.Equal(65.0, routeCoords[0][0].GetDouble(), 2); + Assert.Equal(52.0, routeCoords[0][1].GetDouble(), 2); + + Assert.Equal(82.0, routeCoords[1][0].GetDouble(), 2); + Assert.Equal(56.0, routeCoords[1][1].GetDouble(), 2); + + Assert.Equal(90.0, routeCoords[2][0].GetDouble(), 2); + Assert.Equal(56.0, routeCoords[2][1].GetDouble(), 2); + + var routeCrs = route.GetProperty("crs"); + Assert.Equal("name", routeCrs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", routeCrs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterBinder.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterBinder.cs new file mode 100644 index 000000000..f26a2db4b --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterBinder.cs @@ -0,0 +1,66 @@ +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.OData.UriParser; +using MsGeometry = Microsoft.Spatial.Geometry; +using MsSpatialImplementation = Microsoft.Spatial.SpatialImplementation; +using MsSpatialOperations = Microsoft.Spatial.SpatialOperations; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.LegacyFilter +{ + public class LegacyFilterBinder : FilterBinder + { + private const string GeoDistanceFunctionName = "geo.distance"; + private static readonly MethodInfo GeometryDistanceMethod = typeof(MsSpatialOperations).GetMethod(nameof(MsSpatialOperations.Distance), new[] { typeof(MsGeometry), typeof(MsGeometry) }); + private static readonly PropertyInfo CurrentImplementationProp = typeof(MsSpatialImplementation).GetProperty(nameof(MsSpatialImplementation.CurrentImplementation), BindingFlags.Public | BindingFlags.Static); + private static readonly PropertyInfo OperationsProp = typeof(MsSpatialImplementation).GetProperty(nameof(MsSpatialImplementation.Operations), BindingFlags.Public | BindingFlags.Instance); + + public override Expression BindSingleValueFunctionCallNode(SingleValueFunctionCallNode node, QueryBinderContext context) + { + var arguments = BindArguments(node.Parameters, context); + + if (node.Name == GeoDistanceFunctionName) + { + return BindGeoDistance(node, context); + } + + return base.BindSingleValueFunctionCallNode(node, context); + } + + private Expression BindGeoDistance(SingleValueFunctionCallNode node, QueryBinderContext context) + { + // Expect exactly two parameters: (geomA, geomB) + var arguments = BindArguments(node.Parameters, context); + if (arguments == null || arguments.Length != 2) + { + throw new NotSupportedException($"The function '{GeoDistanceFunctionName}' must have exactly two parameters."); + } + + var currentImplementation = Expression.Property(null, CurrentImplementationProp); + var operations = Expression.Property(currentImplementation, OperationsProp); + + // Ensure both are NTS Geometry expressions + Expression left = EnsureMsGeometry(arguments[0]); + Expression right = EnsureMsGeometry(arguments[1]); + + // Emit left.Distance(right) + return Expression.Call(operations, GeometryDistanceMethod, left, right); + } + + private static Expression EnsureMsGeometry(Expression expression) + { + if (typeof(MsGeometry).IsAssignableFrom(expression.Type)) + { + return expression; + } + + if (expression is ConstantExpression constantExpression) + { + return constantExpression; + } + + // As a last resort, try a convert (will fail at runtime if incompatible) + return Expression.Convert(expression, typeof(MsGeometry)); + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterController.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterController.cs new file mode 100644 index 000000000..b3ff05b6a --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using Microsoft.Spatial; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.LegacyFilter; + +public class SitesController : ODataController +{ + [EnableQuery] + public ActionResult> Get() + { + return new List + { + new Site { Id = 1, Location = GeometryFactory.Point(CoordinateSystem.DefaultGeometry, 47.669444 , -122.123889) }, + new Site { Id = 2, Location = GeometryFactory.Point(CoordinateSystem.DefaultGeometry, 47.608013, -122.335167) } + }; + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterDataModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterDataModel.cs new file mode 100644 index 000000000..72b3d9984 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterDataModel.cs @@ -0,0 +1,10 @@ +using Microsoft.Spatial; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.LegacyFilter +{ + public class Site + { + public int Id { get; set; } + public GeometryPoint Location { get; set; } + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterEdmModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterEdmModel.cs new file mode 100644 index 000000000..14dbfe048 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterEdmModel.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.LegacyFilter; + +public class LegacyFilterEdmModel +{ + public static IEdmModel GetEdmModel() + { + var modelBuilder = new ODataConventionModelBuilder(); + + modelBuilder.EntitySet("Sites"); + + var model = modelBuilder.GetEdmModel(); + + return model; + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterTests.cs new file mode 100644 index 000000000..6df00d5b4 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacyFilterTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.LegacyFilter; + +public class LegacyFilterTests : WebApiTestBase +{ + public LegacyFilterTests(WebApiTestFixture fixture) : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = LegacyFilterEdmModel.GetEdmModel(); + Spatial.SpatialImplementation.CurrentImplementation.Operations = new LegacySpatialOperations(); + + services.ConfigureControllers( + typeof(SitesController)); + + services.AddControllers().AddOData( + options => + options.EnableQueryFeatures().AddRouteComponents( + routePrefix: string.Empty, + model: model, + configureServices: (nestedServices) => nestedServices.AddSingleton())); + } + + protected static void UpdateConfigure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + [Fact] + public async Task Test2Async() + { + // Arrange + var queryUrl = $"/Sites?$filter=geo.distance(Location,geometry'POINT(-122.123889 47.669444)') gt 240.15"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + //Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacySpatialOperations.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacySpatialOperations.cs new file mode 100644 index 000000000..4eb78c4a8 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Query/LegacyFilter/LegacySpatialOperations.cs @@ -0,0 +1,38 @@ +using Microsoft.Spatial; +using MsGeography = Microsoft.Spatial.Geography; +using MsGeometry = Microsoft.Spatial.Geometry; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Query.LegacyFilter +{ + public class LegacySpatialOperations : SpatialOperations + { + public override double Distance(MsGeometry geomA, MsGeometry geomB) + { + // Simple Euclidean distance in the coordinate units (SRID 0 => unitless plane). + if (geomA is GeometryPoint p1 && geomB is GeometryPoint p2) + { + double dx = p1.X - p2.X; + double dy = p1.Y - p2.Y; + double result = Math.Sqrt(dx * dx + dy * dy); + return result; + } + + return base.Distance(geomA, geomB); + } + + public override double Distance(MsGeography operand1, MsGeography operand2) + { + return base.Distance(operand1, operand2); + } + + public override double Length(MsGeometry operand) + { + return base.Length(operand); + } + + public override double Length(MsGeography operand) + { + return base.Length(operand); + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/GeometryCollectionConverterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/GeometryCollectionConverterTests.cs new file mode 100644 index 000000000..a333462b5 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/GeometryCollectionConverterTests.cs @@ -0,0 +1,182 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyCollection = Microsoft.Spatial.GeographyCollection; +using MsGeographyLineString = Microsoft.Spatial.GeographyLineString; +using MsGeographyPoint = Microsoft.Spatial.GeographyPoint; +using MsGeometryCollection = Microsoft.Spatial.GeometryCollection; +using MsGeometryLineString = Microsoft.Spatial.GeometryLineString; +using MsGeometryPoint = Microsoft.Spatial.GeometryPoint; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometry = NetTopologySuite.Geometries.Geometry; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class GeometryCollectionConverterTests +{ + private readonly GeometryCollectionConverter _converter = new(); + + [Fact] + public void CanConvert_ReturnsTrue_For_GeometryCollection_Primitive() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var gc = gf.CreateGeometryCollection(new NtsGeometry[] + { + gf.CreatePoint(new NtsCoordinate(0,0)), + gf.CreateLineString(new[] { new NtsCoordinate(0,0), new NtsCoordinate(1,1) }) + }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryCollection, gc); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsTrue_For_GeographyCollection_Primitive() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var gc = gf.CreateGeometryCollection(new NtsGeometry[] + { + gf.CreatePoint(new NtsCoordinate(-97.6, 30.2)), + gf.CreateLineString(new[] { new NtsCoordinate(-97.6,30.2), new NtsCoordinate(-97.7,30.3) }) + }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyCollection, gc); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_NonCollectionGeometry() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var point = gf.CreatePoint(new NtsCoordinate(1, 2)); + + // Act + bool r1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryCollection, point); + bool r2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyCollection, point); + + // Assert + Assert.False(r1); + Assert.False(r2); + } + + [Fact] + public void Convert_GeometryCollection_Returns_GeometryCollection_With_Srid_And_Items() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var gcNts = gf.CreateGeometryCollection(new NtsGeometry[] + { + gf.CreatePoint(new NtsCoordinate(0.0,0.0)), + gf.CreateLineString(new[] { new NtsCoordinate(1.0,2.0), new NtsCoordinate(2.5,3.5) }) + }); + gcNts.SRID = 3857; + + IEdmPrimitiveTypeReference edmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryCollection, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(gcNts, edmType); + + // Assert + var gcol = Assert.IsAssignableFrom(spatial); + Assert.Equal(3857, gcol.CoordinateSystem.EpsgId); + Assert.False(gcol.IsEmpty); + + var items = gcol.Geometries.ToArray(); + Assert.Equal(2, items.Length); + Assert.IsAssignableFrom(items[0]); + Assert.IsAssignableFrom(items[1]); + + var p = (MsGeometryPoint)items[0]; + Assert.Equal(0.0, p.X, 6); + Assert.Equal(0.0, p.Y, 6); + + var ls = (MsGeometryLineString)items[1]; + var pts = ls.Points.ToArray(); + Assert.Equal(2, pts.Length); + Assert.Equal(1.0, pts[0].X, 6); + Assert.Equal(2.0, pts[0].Y, 6); + Assert.Equal(2.5, pts[1].X, 6); + Assert.Equal(3.5, pts[1].Y, 6); + } + + [Fact] + public void Convert_GeographyCollection_Returns_GeographyCollection_With_Srid_And_Items() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var gcNts = gf.CreateGeometryCollection(new NtsGeometry[] + { + gf.CreatePoint(new NtsCoordinate(-97.617134, 30.222296)), + gf.CreateLineString(new[] + { + new NtsCoordinate(-97.617134, 30.222296), + new NtsCoordinate(-97.700000, 30.300000) + }) + }); + gcNts.SRID = 4326; + + IEdmPrimitiveTypeReference edmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyCollection, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(gcNts, edmType); + + // Assert + var gcol = Assert.IsAssignableFrom(spatial); + Assert.Equal(4326, gcol.CoordinateSystem.EpsgId); + Assert.False(gcol.IsEmpty); + + var items = gcol.Geographies.ToArray(); + Assert.Equal(2, items.Length); + Assert.IsAssignableFrom(items[0]); + Assert.IsAssignableFrom(items[1]); + + var p = (MsGeographyPoint)items[0]; + Assert.Equal(30.222296, p.Latitude, 6); + Assert.Equal(-97.617134, p.Longitude, 6); + + var ls = (MsGeographyLineString)items[1]; + var pts = ls.Points.ToArray(); + Assert.Equal(2, pts.Length); + Assert.Equal(30.222296, pts[0].Latitude, 6); + Assert.Equal(-97.617134, pts[0].Longitude, 6); + Assert.Equal(30.300000, pts[1].Latitude, 6); + Assert.Equal(-97.700000, pts[1].Longitude, 6); + } + + [Fact] + public void Convert_EmptyGeometryCollection_Returns_Empty() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var emptyGc = gf.CreateGeometryCollection(System.Array.Empty()); + emptyGc.SRID = 4326; + + IEdmPrimitiveTypeReference edmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyCollection, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(emptyGc, edmType); + + // Assert + Assert.True(spatial.IsEmpty); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/LineStringConverterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/LineStringConverterTests.cs new file mode 100644 index 000000000..bd240ff95 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/LineStringConverterTests.cs @@ -0,0 +1,183 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyLineString = Microsoft.Spatial.GeographyLineString; +using MsGeographyPoint = Microsoft.Spatial.GeographyPoint; +using MsGeometryLineString = Microsoft.Spatial.GeometryLineString; +using MsGeometryPoint = Microsoft.Spatial.GeometryPoint; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class LineStringConverterTests +{ + private readonly LineStringConverter _converter = new(); + + [Fact] + public void CanConvert_ReturnsTrue_For_GeometryLineString() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var line = gf.CreateLineString(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(1, 1) + }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryLineString, line); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsTrue_For_GeographyLineString() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var line = gf.CreateLineString(new[] + { + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.3) + }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyLineString, line); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_NonLineStringGeometry() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var point = gf.CreatePoint(new NtsCoordinate(1, 2)); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryLineString, point); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyLineString, point); + + // Assert + Assert.False(result1); + Assert.False(result2); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_LineString_With_NonLine_PrimitiveKind() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var line = gf.CreateLineString(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(1, 1) + }); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPoint, line); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyPoint, line); + bool result3 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPolygon, line); + + // Assert + Assert.False(result1); + Assert.False(result2); + Assert.False(result3); + } + + [Fact] + public void Convert_GeometryLineString_Returns_GeometryLineString_With_Srid_And_Points() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsLine = gf.CreateLineString(new[] + { + new NtsCoordinate(0.0, 0.0), + new NtsCoordinate(1.0, 2.0), + new NtsCoordinate(2.5, 3.5) + }); + ntsLine.SRID = 3857; + + IEdmPrimitiveTypeReference geometryEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryLineString, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsLine, geometryEdmType); + + // Assert + var gls = Assert.IsAssignableFrom(spatial); + Assert.Equal(3857, gls.CoordinateSystem.EpsgId); + Assert.False(gls.IsEmpty); + + // Validate vertices (X,Y order) + MsGeometryPoint[] pts = gls.Points.ToArray(); + Assert.Equal(3, pts.Length); + Assert.Equal(0.0, pts[0].X, 6); + Assert.Equal(0.0, pts[0].Y, 6); + Assert.Equal(1.0, pts[1].X, 6); + Assert.Equal(2.0, pts[1].Y, 6); + Assert.Equal(2.5, pts[2].X, 6); + Assert.Equal(3.5, pts[2].Y, 6); + } + + [Fact] + public void Convert_GeographyLineString_Returns_GeographyLineString_With_Srid_And_LatLon() + { + // Arrange + var gf = NtsGeometryFactory.Default; + // X = lon, Y = lat in NTS; Geography expects (lat, lon) + var ntsLine = gf.CreateLineString(new[] + { + new NtsCoordinate(-97.617134, 30.222296), + new NtsCoordinate(-97.700000, 30.300000) + }); + ntsLine.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyLineString, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsLine, geographyEdmType); + + // Assert + var gls = Assert.IsAssignableFrom(spatial); + Assert.Equal(4326, gls.CoordinateSystem.EpsgId); + Assert.False(gls.IsEmpty); + + // Validate vertices (lat, lon order) + MsGeographyPoint[] pts = gls.Points.ToArray(); + Assert.Equal(2, pts.Length); + Assert.Equal(30.222296, pts[0].Latitude, 6); + Assert.Equal(-97.617134, pts[0].Longitude, 6); + Assert.Equal(30.300000, pts[1].Latitude, 6); + Assert.Equal(-97.700000, pts[1].Longitude, 6); + } + + [Fact] + public void Convert_EmptyLineString_Returns_Empty() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsEmpty = gf.CreateLineString(System.Array.Empty()); + ntsEmpty.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyLineString, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsEmpty, geographyEdmType); + + // Assert + Assert.True(spatial.IsEmpty); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiLineStringConverterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiLineStringConverterTests.cs new file mode 100644 index 000000000..70f0ce5de --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiLineStringConverterTests.cs @@ -0,0 +1,208 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyMultiLineString = Microsoft.Spatial.GeographyMultiLineString; +using MsGeographyPoint = Microsoft.Spatial.GeographyPoint; +using MsGeometryMultiLineString = Microsoft.Spatial.GeometryMultiLineString; +using MsGeometryPoint = Microsoft.Spatial.GeometryPoint; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; +using NtsLineString = NetTopologySuite.Geometries.LineString; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class MultiLineStringConverterTests +{ + private readonly MultiLineStringConverter _converter = new(); + + [Fact] + public void CanConvert_ReturnsTrue_For_GeometryMultiLineString() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ls1 = gf.CreateLineString(new[] { new NtsCoordinate(0, 0), new NtsCoordinate(1, 1) }); + var mls = gf.CreateMultiLineString(new[] { ls1 }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiLineString, mls); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsTrue_For_GeographyMultiLineString() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ls1 = gf.CreateLineString(new[] { new NtsCoordinate(-97.6, 30.2), new NtsCoordinate(-97.7, 30.3) }); + var mls = gf.CreateMultiLineString(new[] { ls1 }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyMultiLineString, mls); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_NonMultiLineStringGeometry() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ls = gf.CreateLineString(new[] { new NtsCoordinate(0, 0), new NtsCoordinate(1, 1) }); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiLineString, ls); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyMultiLineString, ls); + + // Assert + Assert.False(result1); + Assert.False(result2); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_MultiLineString_With_NonMultiLine_PrimitiveKind() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ls1 = gf.CreateLineString(new[] { new NtsCoordinate(0, 0), new NtsCoordinate(1, 1) }); + var mls = gf.CreateMultiLineString(new[] { ls1 }); + + // Act + bool r1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryLineString, mls); + bool r2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyLineString, mls); + bool r3 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiPoint, mls); + bool r4 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyMultiPolygon, mls); + + // Assert + Assert.False(r1); + Assert.False(r2); + Assert.False(r3); + Assert.False(r4); + } + + [Fact] + public void Convert_GeometryMultiLineString_Returns_GeometryMultiLineString_With_Srid_And_Points() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ls1 = gf.CreateLineString(new[] + { + new NtsCoordinate(0.0, 0.0), + new NtsCoordinate(1.0, 2.0) + }); + var ls2 = gf.CreateLineString(new[] + { + new NtsCoordinate(2.5, 3.5), + new NtsCoordinate(4.5, 5.5) + }); + var ntsMls = gf.CreateMultiLineString(new NtsLineString[] { ls1, ls2 }); + ntsMls.SRID = 3857; + + IEdmPrimitiveTypeReference edmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryMultiLineString, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsMls, edmType); + + // Assert + var gmls = Assert.IsAssignableFrom(spatial); + Assert.Equal(3857, gmls.CoordinateSystem.EpsgId); + Assert.False(gmls.IsEmpty); + + var lines = gmls.LineStrings.ToArray(); + Assert.Equal(2, lines.Length); + + // Line 1 (X,Y order) + MsGeometryPoint[] l1pts = lines[0].Points.ToArray(); + Assert.Equal(2, l1pts.Length); + Assert.Equal(0.0, l1pts[0].X, 6); + Assert.Equal(0.0, l1pts[0].Y, 6); + Assert.Equal(1.0, l1pts[1].X, 6); + Assert.Equal(2.0, l1pts[1].Y, 6); + + // Line 2 (X,Y order) + MsGeometryPoint[] l2pts = lines[1].Points.ToArray(); + Assert.Equal(2, l2pts.Length); + Assert.Equal(2.5, l2pts[0].X, 6); + Assert.Equal(3.5, l2pts[0].Y, 6); + Assert.Equal(4.5, l2pts[1].X, 6); + Assert.Equal(5.5, l2pts[1].Y, 6); + } + + [Fact] + public void Convert_GeographyMultiLineString_Returns_GeographyMultiLineString_With_Srid_And_LatLon() + { + // Arrange + var gf = NtsGeometryFactory.Default; + // NTS uses (X=lon, Y=lat); Geography expects (lat,lon) + var ls1 = gf.CreateLineString(new[] + { + new NtsCoordinate(-97.617134, 30.222296), + new NtsCoordinate(-97.700000, 30.300000) + }); + var ls2 = gf.CreateLineString(new[] + { + new NtsCoordinate(-97.800000, 30.400000), + new NtsCoordinate(-97.900000, 30.500000) + }); + var ntsMls = gf.CreateMultiLineString(new NtsLineString[] { ls1, ls2 }); + ntsMls.SRID = 4326; + + IEdmPrimitiveTypeReference edmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyMultiLineString, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsMls, edmType); + + // Assert + var gmls = Assert.IsAssignableFrom(spatial); + Assert.Equal(4326, gmls.CoordinateSystem.EpsgId); + Assert.False(gmls.IsEmpty); + + var lines = gmls.LineStrings.ToArray(); + Assert.Equal(2, lines.Length); + + // Line 1 (lat, lon order) + MsGeographyPoint[] l1pts = lines[0].Points.ToArray(); + Assert.Equal(2, l1pts.Length); + Assert.Equal(30.222296, l1pts[0].Latitude, 6); + Assert.Equal(-97.617134, l1pts[0].Longitude, 6); + Assert.Equal(30.300000, l1pts[1].Latitude, 6); + Assert.Equal(-97.700000, l1pts[1].Longitude, 6); + + // Line 2 (lat, lon order) + MsGeographyPoint[] l2pts = lines[1].Points.ToArray(); + Assert.Equal(2, l2pts.Length); + Assert.Equal(30.400000, l2pts[0].Latitude, 6); + Assert.Equal(-97.800000, l2pts[0].Longitude, 6); + Assert.Equal(30.500000, l2pts[1].Latitude, 6); + Assert.Equal(-97.900000, l2pts[1].Longitude, 6); + } + + [Fact] + public void Convert_EmptyMultiLineString_Returns_Empty() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsEmpty = gf.CreateMultiLineString(System.Array.Empty()); + ntsEmpty.SRID = 4326; + + IEdmPrimitiveTypeReference edmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyMultiLineString, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsEmpty, edmType); + + // Assert + Assert.True(spatial.IsEmpty); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiPointConverterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiPointConverterTests.cs new file mode 100644 index 000000000..879bd1f75 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiPointConverterTests.cs @@ -0,0 +1,182 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyMultiPoint = Microsoft.Spatial.GeographyMultiPoint; +using MsGeometryMultiPoint = Microsoft.Spatial.GeometryMultiPoint; +using MsGeographyPoint = Microsoft.Spatial.GeographyPoint; +using MsGeometryPoint = Microsoft.Spatial.GeometryPoint; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class MultiPointConverterTests +{ + private readonly MultiPointConverter _converter = new(); + + [Fact] + public void CanConvert_ReturnsTrue_For_GeometryMultiPoint() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var mp = gf.CreateMultiPointFromCoords(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(1, 1) + }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiPoint, mp); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsTrue_For_GeographyMultiPoint() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var mp = gf.CreateMultiPointFromCoords(new[] + { + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.3) + }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyMultiPoint, mp); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_NonMultiPointGeometry() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var point = gf.CreatePoint(new NtsCoordinate(1, 2)); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiPoint, point); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyMultiPoint, point); + + // Assert + Assert.False(result1); + Assert.False(result2); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_MultiPoint_With_NonMultiPoint_PrimitiveKind() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var mp = gf.CreateMultiPointFromCoords(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(1, 1) + }); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPoint, mp); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyLineString, mp); + bool result3 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiLineString, mp); + + // Assert + Assert.False(result1); + Assert.False(result2); + Assert.False(result3); + } + + [Fact] + public void Convert_GeometryMultiPoint_Returns_GeometryMultiPoint_With_Srid_And_Points() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsMultiPoint = gf.CreateMultiPointFromCoords(new[] + { + new NtsCoordinate(0.0, 0.0), + new NtsCoordinate(1.0, 2.0), + new NtsCoordinate(2.5, 3.5) + }); + ntsMultiPoint.SRID = 3857; + + IEdmPrimitiveTypeReference geometryEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryMultiPoint, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsMultiPoint, geometryEdmType); + + // Assert + var gmp = Assert.IsAssignableFrom(spatial); + Assert.Equal(3857, gmp.CoordinateSystem.EpsgId); + Assert.False(gmp.IsEmpty); + + // Validate vertices (X,Y order) + MsGeometryPoint[] pts = gmp.Points.ToArray(); + Assert.Equal(3, pts.Length); + Assert.Equal(0.0, pts[0].X, 6); + Assert.Equal(0.0, pts[0].Y, 6); + Assert.Equal(1.0, pts[1].X, 6); + Assert.Equal(2.0, pts[1].Y, 6); + Assert.Equal(2.5, pts[2].X, 6); + Assert.Equal(3.5, pts[2].Y, 6); + } + + [Fact] + public void Convert_GeographyMultiPoint_Returns_GeographyMultiPoint_With_Srid_And_LatLon() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsMultiPoint = gf.CreateMultiPointFromCoords(new[] + { + new NtsCoordinate(-97.617134, 30.222296), + new NtsCoordinate(-97.700000, 30.300000) + }); + ntsMultiPoint.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyMultiPoint, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsMultiPoint, geographyEdmType); + + // Assert + var gmp = Assert.IsAssignableFrom(spatial); + Assert.Equal(4326, gmp.CoordinateSystem.EpsgId); + Assert.False(gmp.IsEmpty); + + // Validate vertices (lat, lon order) + MsGeographyPoint[] pts = gmp.Points.ToArray(); + Assert.Equal(2, pts.Length); + Assert.Equal(30.222296, pts[0].Latitude, 6); + Assert.Equal(-97.617134, pts[0].Longitude, 6); + Assert.Equal(30.300000, pts[1].Latitude, 6); + Assert.Equal(-97.700000, pts[1].Longitude, 6); + } + + [Fact] + public void Convert_EmptyMultiPoint_Returns_Empty() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsEmpty = gf.CreateMultiPoint(); // empty multipoint + ntsEmpty.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyMultiPoint, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsEmpty, geographyEdmType); + + // Assert + Assert.True(spatial.IsEmpty); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiPolygonConverterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiPolygonConverterTests.cs new file mode 100644 index 000000000..e40a6a2ea --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/MultiPolygonConverterTests.cs @@ -0,0 +1,219 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyMultiPolygon = Microsoft.Spatial.GeographyMultiPolygon; +using MsGeographyPolygon = Microsoft.Spatial.GeographyPolygon; +using MsGeometryMultiPolygon = Microsoft.Spatial.GeometryMultiPolygon; +using MsGeometryPolygon = Microsoft.Spatial.GeometryPolygon; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; +using NtsPolygon = NetTopologySuite.Geometries.Polygon; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class MultiPolygonConverterTests +{ + private readonly MultiPolygonConverter _converter = new(); + + [Fact] + public void CanConvert_ReturnsTrue_For_GeometryMultiPolygon() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var p1 = gf.CreatePolygon(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(0, 1), + new NtsCoordinate(1, 1), + new NtsCoordinate(1, 0), + new NtsCoordinate(0, 0) + }); + var mp = gf.CreateMultiPolygon(new NtsPolygon[] { p1 }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiPolygon, mp); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsTrue_For_GeographyMultiPolygon() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var p1 = gf.CreatePolygon(new[] + { + new NtsCoordinate(-97.7, 30.2), + new NtsCoordinate(-97.7, 30.3), + new NtsCoordinate(-97.6, 30.3), + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.2) + }); + var mp = gf.CreateMultiPolygon(new NtsPolygon[] { p1 }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyMultiPolygon, mp); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_NonMultiPolygonGeometry() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var singlePolygon = gf.CreatePolygon(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(0, 1), + new NtsCoordinate(1, 1), + new NtsCoordinate(1, 0), + new NtsCoordinate(0, 0) + }); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiPolygon, singlePolygon); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyMultiPolygon, singlePolygon); + + // Assert + Assert.False(result1); + Assert.False(result2); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_MultiPolygon_With_NonMultiPolygon_PrimitiveKind() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var p1 = gf.CreatePolygon(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(0, 1), + new NtsCoordinate(1, 1), + new NtsCoordinate(1, 0), + new NtsCoordinate(0, 0) + }); + var mp = gf.CreateMultiPolygon(new NtsPolygon[] { p1 }); + + // Act + bool r1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPolygon, mp); + bool r2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyLineString, mp); + bool r3 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiLineString, mp); + + // Assert + Assert.False(r1); + Assert.False(r2); + Assert.False(r3); + } + + [Fact] + public void Convert_GeometryMultiPolygon_Returns_GeometryMultiPolygon_With_Srid_And_Polygons() + { + // Arrange + var gf = NtsGeometryFactory.Default; + + var p1 = gf.CreatePolygon(new[] + { + new NtsCoordinate(0.0, 0.0), + new NtsCoordinate(0.0, 1.0), + new NtsCoordinate(1.0, 1.0), + new NtsCoordinate(1.0, 0.0), + new NtsCoordinate(0.0, 0.0) + }); + + var p2 = gf.CreatePolygon(new[] + { + new NtsCoordinate(2.0, 2.0), + new NtsCoordinate(2.0, 3.0), + new NtsCoordinate(3.0, 3.0), + new NtsCoordinate(3.0, 2.0), + new NtsCoordinate(2.0, 2.0) + }); + + var ntsMp = gf.CreateMultiPolygon(new NtsPolygon[] { p1, p2 }); + ntsMp.SRID = 3857; + + IEdmPrimitiveTypeReference geometryEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryMultiPolygon, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsMp, geometryEdmType); + + // Assert + var gmp = Assert.IsAssignableFrom(spatial); + Assert.Equal(3857, gmp.CoordinateSystem.EpsgId); + Assert.False(gmp.IsEmpty); + + MsGeometryPolygon[] polys = gmp.Polygons.ToArray(); + Assert.Equal(2, polys.Length); + } + + [Fact] + public void Convert_GeographyMultiPolygon_Returns_GeographyMultiPolygon_With_Srid_And_Polygons() + { + // Arrange + var gf = NtsGeometryFactory.Default; + + var p1 = gf.CreatePolygon(new[] + { + new NtsCoordinate(-97.7, 30.2), + new NtsCoordinate(-97.7, 30.3), + new NtsCoordinate(-97.6, 30.3), + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.2) + }); + + var p2 = gf.CreatePolygon(new[] + { + new NtsCoordinate(-97.5, 30.1), + new NtsCoordinate(-97.5, 30.15), + new NtsCoordinate(-97.45, 30.15), + new NtsCoordinate(-97.45, 30.1), + new NtsCoordinate(-97.5, 30.1) + }); + + var ntsMp = gf.CreateMultiPolygon(new NtsPolygon[] { p1, p2 }); + ntsMp.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyMultiPolygon, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsMp, geographyEdmType); + + // Assert + var gmp = Assert.IsAssignableFrom(spatial); + Assert.Equal(4326, gmp.CoordinateSystem.EpsgId); + Assert.False(gmp.IsEmpty); + + MsGeographyPolygon[] polys = gmp.Polygons.ToArray(); + Assert.Equal(2, polys.Length); + } + + [Fact] + public void Convert_EmptyMultiPolygon_Returns_Empty() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsEmpty = gf.CreateMultiPolygon(); // empty multipolygon + ntsEmpty.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyMultiPolygon, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsEmpty, geographyEdmType); + + // Assert + Assert.True(spatial.IsEmpty); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/PointConverterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/PointConverterTests.cs new file mode 100644 index 000000000..13825c853 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/PointConverterTests.cs @@ -0,0 +1,146 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyPoint = Microsoft.Spatial.GeographyPoint; +using MsGeometryPoint = Microsoft.Spatial.GeometryPoint; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class PointConverterTests +{ + private readonly PointConverter _converter = new(); + + [Fact] + public void CanConvert_ReturnsTrue_For_GeometryPoint() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var point = gf.CreatePoint(new NtsCoordinate(10, 20)); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPoint, point); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsTrue_For_GeographyPoint() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var point = gf.CreatePoint(new NtsCoordinate(-97.617134, 30.222296)); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyPoint, point); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_NonPointGeometry() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var line = gf.CreateLineString(new[] + { + new NtsCoordinate(0,0), + new NtsCoordinate(1,1) + }); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPoint, line); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyPoint, line); + + // Assert + Assert.False(result1); + Assert.False(result2); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_Point_With_NonPoint_PrimitiveKind() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var point = gf.CreatePoint(new NtsCoordinate(1, 2)); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.Geometry, point); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.Geography, point); + bool result3 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryLineString, point); + + // Assert + Assert.False(result1); + Assert.False(result2); + Assert.False(result3); + } + + [Fact] + public void Convert_GeometryPoint_Returns_GeometryPoint_With_XY_And_Srid() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsPoint = gf.CreatePoint(new NtsCoordinate(12.34, 56.78)); + ntsPoint.SRID = 3857; // arbitrary non-default SRID for geometry + + IEdmPrimitiveTypeReference geometryEdmType = EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryPoint, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsPoint, geometryEdmType); + + // Assert + var gp = Assert.IsAssignableFrom(spatial); + Assert.Equal(12.34, gp.X, 5); + Assert.Equal(56.78, gp.Y, 5); + Assert.Equal(3857, gp.CoordinateSystem.EpsgId); + Assert.False(gp.IsEmpty); + } + + [Fact] + public void Convert_GeographyPoint_Returns_GeographyPoint_With_LatLon_And_Srid() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsPoint = gf.CreatePoint(new NtsCoordinate(-97.617134, 30.222296)); // X = lon, Y = lat + ntsPoint.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyPoint, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsPoint, geographyEdmType); + + // Assert + var gp = Assert.IsAssignableFrom(spatial); + Assert.Equal(30.222296, gp.Latitude, 6); // Y -> Latitude + Assert.Equal(-97.617134, gp.Longitude, 6); // X -> Longitude + Assert.Equal(4326, gp.CoordinateSystem.EpsgId); + Assert.False(gp.IsEmpty); + } + + [Fact] + public void Convert_EmptyPoint_IfSupported_Returns_Empty() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsEmptyPoint = gf.CreatePoint(); // empty point + ntsEmptyPoint.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyPoint, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsEmptyPoint, geographyEdmType); + + // Assert + Assert.True(spatial.IsEmpty); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/PolygonConverterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/PolygonConverterTests.cs new file mode 100644 index 000000000..b642f7de2 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/PolygonConverterTests.cs @@ -0,0 +1,177 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyPolygon = Microsoft.Spatial.GeographyPolygon; +using MsGeometryPolygon = Microsoft.Spatial.GeometryPolygon; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class PolygonConverterTests +{ + private readonly PolygonConverter _converter = new(); + + [Fact] + public void CanConvert_ReturnsTrue_For_GeometryPolygon() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var polygon = gf.CreatePolygon(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(0, 1), + new NtsCoordinate(1, 1), + new NtsCoordinate(1, 0), + new NtsCoordinate(0, 0) + }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPolygon, polygon); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsTrue_For_GeographyPolygon() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var polygon = gf.CreatePolygon(new[] + { + new NtsCoordinate(-97.7, 30.2), + new NtsCoordinate(-97.7, 30.3), + new NtsCoordinate(-97.6, 30.3), + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.2) + }); + + // Act + bool result = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyPolygon, polygon); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_NonPolygonGeometry() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var point = gf.CreatePoint(new NtsCoordinate(1, 2)); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPolygon, point); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyPolygon, point); + + // Assert + Assert.False(result1); + Assert.False(result2); + } + + [Fact] + public void CanConvert_ReturnsFalse_For_Polygon_With_NonPolygon_PrimitiveKind() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var polygon = gf.CreatePolygon(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(0, 1), + new NtsCoordinate(1, 1), + new NtsCoordinate(1, 0), + new NtsCoordinate(0, 0) + }); + + // Act + bool result1 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryPoint, polygon); + bool result2 = _converter.CanConvert(EdmPrimitiveTypeKind.GeographyLineString, polygon); + bool result3 = _converter.CanConvert(EdmPrimitiveTypeKind.GeometryMultiPolygon, polygon); + + // Assert + Assert.False(result1); + Assert.False(result2); + Assert.False(result3); + } + + [Fact] + public void Convert_GeometryPolygon_Returns_GeometryPolygon_With_Srid() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsPolygon = gf.CreatePolygon(new[] + { + new NtsCoordinate(0.0, 0.0), + new NtsCoordinate(0.0, 1.0), + new NtsCoordinate(1.0, 1.0), + new NtsCoordinate(1.0, 0.0), + new NtsCoordinate(0.0, 0.0) + }); + ntsPolygon.SRID = 3857; + + IEdmPrimitiveTypeReference geometryEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryPolygon, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsPolygon, geometryEdmType); + + // Assert + var gp = Assert.IsAssignableFrom(spatial); + Assert.Equal(3857, gp.CoordinateSystem.EpsgId); + Assert.False(gp.IsEmpty); + } + + [Fact] + public void Convert_GeographyPolygon_Returns_GeographyPolygon_With_Srid() + { + // Arrange + var gf = NtsGeometryFactory.Default; + // NTS uses (X=lon, Y=lat) but geography expects (lat,lon). Mapping is handled by converter. + var ntsPolygon = gf.CreatePolygon(new[] + { + new NtsCoordinate(-97.7, 30.2), + new NtsCoordinate(-97.7, 30.3), + new NtsCoordinate(-97.6, 30.3), + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.2) + }); + ntsPolygon.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyPolygon, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsPolygon, geographyEdmType); + + // Assert + var gp = Assert.IsAssignableFrom(spatial); + Assert.Equal(4326, gp.CoordinateSystem.EpsgId); + Assert.False(gp.IsEmpty); + } + + [Fact] + public void Convert_EmptyPolygon_Returns_Empty() + { + // Arrange + var gf = NtsGeometryFactory.Default; + var ntsEmpty = gf.CreatePolygon(); // empty polygon + ntsEmpty.SRID = 4326; + + IEdmPrimitiveTypeReference geographyEdmType = + EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyPolygon, isNullable: false); + + // Act + ISpatial spatial = _converter.Convert(ntsEmpty, geographyEdmType); + + // Assert + Assert.True(spatial.IsEmpty); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/SpatialConverterRegistryTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/SpatialConverterRegistryTests.cs new file mode 100644 index 000000000..2f0ba7ae4 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/SpatialConverterRegistryTests.cs @@ -0,0 +1,183 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyPoint = Microsoft.Spatial.GeographyPoint; +using MsGeometryPoint = Microsoft.Spatial.GeometryPoint; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometry = NetTopologySuite.Geometries.Geometry; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; +using NtsPoint = NetTopologySuite.Geometries.Point; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class SpatialConverterRegistryTests +{ + [Fact] + public void Convert_UsesFirstMatchingConverter_InRegistrationOrder() + { + // Arrange: two converters both claim they can convert; first should be used. + var gf = NtsGeometryFactory.Default; + NtsPoint point = gf.CreatePoint(new NtsCoordinate(1, 2)); + point.SRID = 0; + + var edmType = EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryPoint, isNullable: false); + + var first = new CountingConverter(canConvert: true, resultFactory: () => new DummySpatial(CoordinateSystem.DefaultGeometry)); + var second = new CountingConverter(canConvert: true, resultFactory: () => new DummySpatial(CoordinateSystem.DefaultGeometry)); + + var registry = new SpatialConverterRegistry(new ISpatialConverter[] { first, second }); + + // Act + ISpatial result = registry.Convert(point, edmType); + + // Assert + Assert.IsType(result); + Assert.Equal(1, first.ConvertCalls); + Assert.Equal(0, second.ConvertCalls); + Assert.True(first.CanConvertCalls >= 1); // called at least once + } + + [Fact] + public void Convert_Throws_InvalidOperation_When_NoConverterMatches() + { + // Arrange + var gf = NtsGeometryFactory.Default; + NtsPoint point = gf.CreatePoint(new NtsCoordinate(1, 2)); + var edmType = EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryLineString, isNullable: false); // mismatch on purpose + + var never = new CountingConverter(canConvert: false, resultFactory: () => new DummySpatial(CoordinateSystem.DefaultGeometry)); + var registry = new SpatialConverterRegistry(new ISpatialConverter[] { never }); + + // Act & Assert + var ex = Assert.Throws(() => registry.Convert(point, edmType)); + Assert.Contains(typeof(NtsPoint).FullName!, ex.Message); + } + + [Fact] + public void Convert_Delegates_To_PointConverter_For_GeometryPoint() + { + // Arrange + var gf = NtsGeometryFactory.Default; + NtsPoint ntsPoint = gf.CreatePoint(new NtsCoordinate(12.34, 56.78)); + ntsPoint.SRID = 3857; + + IEdmPrimitiveTypeReference edm = EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryPoint, isNullable: false); + + var registry = new SpatialConverterRegistry(new ISpatialConverter[] + { + new LineStringConverter(), // non-matching first + new PointConverter() + }); + + // Act + ISpatial spatial = registry.Convert(ntsPoint, edm); + + // Assert + var gp = Assert.IsAssignableFrom(spatial); + Assert.Equal(12.34, gp.X, 6); + Assert.Equal(56.78, gp.Y, 6); + Assert.Equal(3857, gp.CoordinateSystem.EpsgId); + } + + [Fact] + public void Convert_Delegates_To_PointConverter_For_GeographyPoint() + { + // Arrange + var gf = NtsGeometryFactory.Default; + NtsPoint ntsPoint = gf.CreatePoint(new NtsCoordinate(-97.617134, 30.222296)); + ntsPoint.SRID = 4326; + + IEdmPrimitiveTypeReference edm = EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeographyPoint, isNullable: false); + + var registry = new SpatialConverterRegistry(new ISpatialConverter[] + { + new LineStringConverter(), // non-matching first + new PointConverter() + }); + + // Act + ISpatial spatial = registry.Convert(ntsPoint, edm); + + // Assert + var gp = Assert.IsAssignableFrom(spatial); + Assert.Equal(30.222296, gp.Latitude, 6); + Assert.Equal(-97.617134, gp.Longitude, 6); + Assert.Equal(4326, gp.CoordinateSystem.EpsgId); + } + + [Fact] + public void Convert_Chooses_Correct_Converter_Among_Many() + { + // Arrange + var gf = NtsGeometryFactory.Default; + NtsPoint point = gf.CreatePoint(new NtsCoordinate(0, 0)); + point.SRID = 0; + + var edmGeomPoint = EdmCoreModel.Instance.GetPrimitive(EdmPrimitiveTypeKind.GeometryPoint, isNullable: false); + + var converters = new ISpatialConverter[] + { + new LineStringConverter(), + new PolygonConverter(), + new MultiPointConverter(), + new MultiLineStringConverter(), + new MultiPolygonConverter(), + new GeometryCollectionConverter(), + new PointConverter() // correct one is last + }; + + var registry = new SpatialConverterRegistry(converters); + + // Act + var result = registry.Convert(point, edmGeomPoint); + + // Assert + Assert.IsAssignableFrom(result); + } + + private sealed class CountingConverter : ISpatialConverter + { + private readonly bool _canConvert; + private readonly Func _resultFactory; + + public int CanConvertCalls { get; private set; } + public int ConvertCalls { get; private set; } + + public CountingConverter(bool canConvert, Func resultFactory) + { + _canConvert = canConvert; + _resultFactory = resultFactory; + } + + public bool CanConvert(EdmPrimitiveTypeKind primitiveTypeKind, NtsGeometry geometry) + { + CanConvertCalls++; + return _canConvert; + } + + public ISpatial Convert(NtsGeometry geometry, IEdmPrimitiveTypeReference primitiveType) + { + ConvertCalls++; + return _resultFactory(); + } + } + + private sealed class DummySpatial : ISpatial + { + public DummySpatial(CoordinateSystem cs, bool isEmpty = false) + { + CoordinateSystem = cs; + IsEmpty = isEmpty; + } + + public CoordinateSystem CoordinateSystem { get; } + public bool IsEmpty { get; } + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/SpatialConverterTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/SpatialConverterTests.cs new file mode 100644 index 000000000..ceafaa085 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Converters/SpatialConverterTests.cs @@ -0,0 +1,364 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Formatter.Serialization.Converters; +using Microsoft.OData.Edm; +using Microsoft.Spatial; +using MsGeographyCollection = Microsoft.Spatial.GeographyCollection; +using MsGeographyLineString = Microsoft.Spatial.GeographyLineString; +using MsGeographyMultiLineString = Microsoft.Spatial.GeographyMultiLineString; +using MsGeographyMultiPoint = Microsoft.Spatial.GeographyMultiPoint; +using MsGeographyMultiPolygon = Microsoft.Spatial.GeographyMultiPolygon; +using MsGeographyPoint = Microsoft.Spatial.GeographyPoint; +using MsGeographyPolygon = Microsoft.Spatial.GeographyPolygon; +using MsGeometryCollection = Microsoft.Spatial.GeometryCollection; +using MsGeometryLineString = Microsoft.Spatial.GeometryLineString; +using MsGeometryMultiLineString = Microsoft.Spatial.GeometryMultiLineString; +using MsGeometryMultiPoint = Microsoft.Spatial.GeometryMultiPoint; +using MsGeometryMultiPolygon = Microsoft.Spatial.GeometryMultiPolygon; +using MsGeometryPoint = Microsoft.Spatial.GeometryPoint; +using MsGeometryPolygon = Microsoft.Spatial.GeometryPolygon; +using NtsCoordinate = NetTopologySuite.Geometries.Coordinate; +using NtsGeometry = NetTopologySuite.Geometries.Geometry; +using NtsGeometryCollection = NetTopologySuite.Geometries.GeometryCollection; +using NtsGeometryFactory = NetTopologySuite.Geometries.GeometryFactory; +using NtsLineString = NetTopologySuite.Geometries.LineString; +using NtsMultiLineString = NetTopologySuite.Geometries.MultiLineString; +using NtsMultiPoint = NetTopologySuite.Geometries.MultiPoint; +using NtsMultiPolygon = NetTopologySuite.Geometries.MultiPolygon; +using NtsPoint = NetTopologySuite.Geometries.Point; +using NtsPolygon = NetTopologySuite.Geometries.Polygon; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Converters; + +public class SpatialConverterTests +{ + private static IEdmPrimitiveTypeReference Edm(EdmPrimitiveTypeKind kind) => + EdmCoreModel.Instance.GetPrimitive(kind, isNullable: false); + + [Fact] + public void Convert_Geometry_Point_Maps_XY_And_Srid() + { + var gf = NtsGeometryFactory.Default; + NtsPoint nts = gf.CreatePoint(new NtsCoordinate(12.34, 56.78)); + nts.SRID = 3857; + + var converter = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeometryPoint), nts.SRID); + + ISpatial spatial = converter.Convert(nts); + + var gp = Assert.IsAssignableFrom(spatial); + Assert.Equal(12.34, gp.X, 6); + Assert.Equal(56.78, gp.Y, 6); + Assert.Equal(3857, gp.CoordinateSystem.EpsgId); + Assert.False(gp.IsEmpty); + } + + [Fact] + public void Convert_Geography_Point_Maps_LatLon_And_Srid() + { + var gf = NtsGeometryFactory.Default; + NtsPoint nts = gf.CreatePoint(new NtsCoordinate(-97.617134, 30.222296)); + nts.SRID = 4326; + + var converter = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyPoint), nts.SRID); + + ISpatial spatial = converter.Convert(nts); + + var gp = Assert.IsAssignableFrom(spatial); + Assert.Equal(30.222296, gp.Latitude, 6); + Assert.Equal(-97.617134, gp.Longitude, 6); + Assert.Equal(4326, gp.CoordinateSystem.EpsgId); + Assert.False(gp.IsEmpty); + } + + [Fact] + public void Convert_LineString_Geometry_Maps_XY() + { + var gf = NtsGeometryFactory.Default; + NtsLineString nts = gf.CreateLineString(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(1, 2), + new NtsCoordinate(2.5, 3.5) + }); + nts.SRID = 0; + + var converter = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeometryLineString), nts.SRID); + + ISpatial spatial = converter.Convert(nts); + + var ls = Assert.IsAssignableFrom(spatial); + Assert.False(ls.IsEmpty); + var pts = ls.Points.ToArray(); + Assert.Equal(3, pts.Length); + Assert.Equal(0, pts[0].X, 6); Assert.Equal(0, pts[0].Y, 6); + Assert.Equal(1, pts[1].X, 6); Assert.Equal(2, pts[1].Y, 6); + Assert.Equal(2.5, pts[2].X, 6); Assert.Equal(3.5, pts[2].Y, 6); + } + + [Fact] + public void Convert_LineString_Geography_Maps_LatLon() + { + var gf = NtsGeometryFactory.Default; + NtsLineString nts = gf.CreateLineString(new[] + { + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.3) + }); + nts.SRID = 4326; + + var converter = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyLineString), nts.SRID); + + ISpatial spatial = converter.Convert(nts); + + var ls = Assert.IsAssignableFrom(spatial); + var pts = ls.Points.ToArray(); + Assert.Equal(2, pts.Length); + Assert.Equal(30.2, pts[0].Latitude, 6); Assert.Equal(-97.6, pts[0].Longitude, 6); + Assert.Equal(30.3, pts[1].Latitude, 6); Assert.Equal(-97.7, pts[1].Longitude, 6); + } + + [Fact] + public void Convert_Polygon_Geometry_Simple_Shell() + { + var gf = NtsGeometryFactory.Default; + NtsPolygon nts = gf.CreatePolygon(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(0, 1), + new NtsCoordinate(1, 1), + new NtsCoordinate(1, 0), + new NtsCoordinate(0, 0) + }); + nts.SRID = 3857; + + var converter = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeometryPolygon), nts.SRID); + + ISpatial spatial = converter.Convert(nts); + + var p = Assert.IsAssignableFrom(spatial); + Assert.Equal(3857, p.CoordinateSystem.EpsgId); + Assert.False(p.IsEmpty); + } + + [Fact] + public void Convert_Polygon_Geography_Simple_Shell() + { + var gf = NtsGeometryFactory.Default; + NtsPolygon nts = gf.CreatePolygon(new[] + { + new NtsCoordinate(-97.7, 30.2), + new NtsCoordinate(-97.7, 30.3), + new NtsCoordinate(-97.6, 30.3), + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.2) + }); + nts.SRID = 4326; + + var converter = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyPolygon), nts.SRID); + + ISpatial spatial = converter.Convert(nts); + + var p = Assert.IsAssignableFrom(spatial); + Assert.Equal(4326, p.CoordinateSystem.EpsgId); + Assert.False(p.IsEmpty); + } + + [Fact] + public void Convert_MultiPoint_Geometry_And_Geography() + { + var gf = NtsGeometryFactory.Default; + NtsMultiPoint ntsGeom = gf.CreateMultiPointFromCoords(new[] + { + new NtsCoordinate(0, 0), + new NtsCoordinate(1, 2) + }); + ntsGeom.SRID = 0; + + NtsMultiPoint ntsGeog = gf.CreateMultiPointFromCoords(new[] + { + new NtsCoordinate(-97.6, 30.2), + new NtsCoordinate(-97.7, 30.3) + }); + ntsGeog.SRID = 4326; + + var converterGeom = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeometryMultiPoint), ntsGeom.SRID); + var converterGeog = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyMultiPoint), ntsGeog.SRID); + + var gmp = Assert.IsAssignableFrom(converterGeom.Convert(ntsGeom)); + var ggp = Assert.IsAssignableFrom(converterGeog.Convert(ntsGeog)); + + var gPts = gmp.Points.ToArray(); + Assert.Equal(2, gPts.Length); + Assert.Equal(0, gPts[0].X, 6); Assert.Equal(0, gPts[0].Y, 6); + Assert.Equal(1, gPts[1].X, 6); Assert.Equal(2, gPts[1].Y, 6); + + var ggPts = ggp.Points.ToArray(); + Assert.Equal(2, ggPts.Length); + Assert.Equal(30.2, ggPts[0].Latitude, 6); Assert.Equal(-97.6, ggPts[0].Longitude, 6); + Assert.Equal(30.3, ggPts[1].Latitude, 6); Assert.Equal(-97.7, ggPts[1].Longitude, 6); + } + + [Fact] + public void Convert_MultiLineString_Geometry_And_Geography() + { + var gf = NtsGeometryFactory.Default; + NtsLineString ls1 = gf.CreateLineString(new[] { new NtsCoordinate(0, 0), new NtsCoordinate(1, 1) }); + NtsLineString ls2 = gf.CreateLineString(new[] { new NtsCoordinate(2, 2), new NtsCoordinate(3, 3) }); + NtsMultiLineString ntsGeom = gf.CreateMultiLineString(new[] { ls1, ls2 }); + ntsGeom.SRID = 3857; + + NtsLineString gls1 = gf.CreateLineString(new[] { new NtsCoordinate(-97.6, 30.2), new NtsCoordinate(-97.7, 30.3) }); + NtsLineString gls2 = gf.CreateLineString(new[] { new NtsCoordinate(-97.8, 30.4), new NtsCoordinate(-97.9, 30.5) }); + NtsMultiLineString ntsGeog = gf.CreateMultiLineString(new[] { gls1, gls2 }); + ntsGeog.SRID = 4326; + + var converterGeom = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeometryMultiLineString), ntsGeom.SRID); + var converterGeog = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyMultiLineString), ntsGeog.SRID); + + var gmls = Assert.IsAssignableFrom(converterGeom.Convert(ntsGeom)); + var ggls = Assert.IsAssignableFrom(converterGeog.Convert(ntsGeog)); + + Assert.Equal(2, gmls.LineStrings.Count()); + Assert.Equal(2, ggls.LineStrings.Count()); + } + + [Fact] + public void Convert_MultiPolygon_Geometry_And_Geography() + { + var gf = NtsGeometryFactory.Default; + + NtsPolygon p1 = gf.CreatePolygon(new[] + { + new NtsCoordinate(0,0), new NtsCoordinate(0,1), new NtsCoordinate(1,1), new NtsCoordinate(1,0), new NtsCoordinate(0,0) + }); + NtsPolygon p2 = gf.CreatePolygon(new[] + { + new NtsCoordinate(2,2), new NtsCoordinate(2,3), new NtsCoordinate(3,3), new NtsCoordinate(3,2), new NtsCoordinate(2,2) + }); + NtsMultiPolygon ntsGeom = gf.CreateMultiPolygon(new[] { p1, p2 }); + ntsGeom.SRID = 3857; + + NtsPolygon gp1 = gf.CreatePolygon(new[] + { + new NtsCoordinate(-97.7,30.2), new NtsCoordinate(-97.7,30.3), new NtsCoordinate(-97.6,30.3), + new NtsCoordinate(-97.6,30.2), new NtsCoordinate(-97.7,30.2) + }); + NtsPolygon gp2 = gf.CreatePolygon(new[] + { + new NtsCoordinate(-97.5,30.1), new NtsCoordinate(-97.5,30.15), new NtsCoordinate(-97.45,30.15), + new NtsCoordinate(-97.45,30.1), new NtsCoordinate(-97.5,30.1) + }); + NtsMultiPolygon ntsGeog = gf.CreateMultiPolygon(new[] { gp1, gp2 }); + ntsGeog.SRID = 4326; + + var converterGeom = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeometryMultiPolygon), ntsGeom.SRID); + var converterGeog = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyMultiPolygon), ntsGeog.SRID); + + var gmp = Assert.IsAssignableFrom(converterGeom.Convert(ntsGeom)); + var ggp = Assert.IsAssignableFrom(converterGeog.Convert(ntsGeog)); + + Assert.Equal(2, gmp.Polygons.Count()); + Assert.Equal(2, ggp.Polygons.Count()); + } + + [Fact] + public void Convert_GeometryCollection_Geometry_And_Geography_Mixed_Nested() + { + var gf = NtsGeometryFactory.Default; + + // Geometry collection with nested collection + NtsPoint p = gf.CreatePoint(new NtsCoordinate(0, 0)); + NtsLineString ls = gf.CreateLineString(new[] { new NtsCoordinate(1, 2), new NtsCoordinate(2, 3) }); + NtsGeometryCollection nested = gf.CreateGeometryCollection(new NtsGeometry[] { gf.CreatePoint(new NtsCoordinate(5, 5)) }); + NtsGeometryCollection ntsGeomCol = gf.CreateGeometryCollection(new NtsGeometry[] { p, ls, nested }); + ntsGeomCol.SRID = 0; + + var convGeom = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeometryCollection), ntsGeomCol.SRID); + var resultGeom = Assert.IsAssignableFrom(convGeom.Convert(ntsGeomCol)); + var itemsG = resultGeom.Geometries.ToArray(); + Assert.Equal(3, itemsG.Length); + Assert.IsAssignableFrom(itemsG[0]); + Assert.IsAssignableFrom(itemsG[1]); + Assert.IsAssignableFrom(itemsG[2]); + + // Geography collection + NtsPoint gp = gf.CreatePoint(new NtsCoordinate(-97.617134, 30.222296)); + NtsLineString gls = gf.CreateLineString(new[] { + new NtsCoordinate(-97.617134, 30.222296), new NtsCoordinate(-97.7, 30.3) + }); + NtsGeometryCollection gNested = gf.CreateGeometryCollection(new NtsGeometry[] { gf.CreatePoint(new NtsCoordinate(-97.8, 30.4)) }); + NtsGeometryCollection ntsGeogCol = gf.CreateGeometryCollection(new NtsGeometry[] { gp, gls, gNested }); + ntsGeogCol.SRID = 4326; + + var convGeog = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyCollection), ntsGeogCol.SRID); + var resultGeog = Assert.IsAssignableFrom(convGeog.Convert(ntsGeogCol)); + var itemsGeog = resultGeog.Geographies.ToArray(); + Assert.Equal(3, itemsGeog.Length); + Assert.IsAssignableFrom(itemsGeog[0]); + Assert.IsAssignableFrom(itemsGeog[1]); + Assert.IsAssignableFrom(itemsGeog[2]); + } + + [Fact] + public void Convert_Empty_Shapes_Return_Empty() + { + var gf = NtsGeometryFactory.Default; + + // Empty point + NtsPoint ep = gf.CreatePoint(); + ep.SRID = 4326; + var convP = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyPoint), ep.SRID); + Assert.True(convP.Convert(ep).IsEmpty); + + // Empty line string + NtsLineString els = gf.CreateLineString(Array.Empty()); + els.SRID = 4326; + var convLs = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyLineString), els.SRID); + Assert.True(convLs.Convert(els).IsEmpty); + + // Empty polygon + NtsPolygon epoly = gf.CreatePolygon(); + epoly.SRID = 4326; + var convPoly = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyPolygon), epoly.SRID); + Assert.True(convPoly.Convert(epoly).IsEmpty); + + // Empty geometry collection + NtsGeometryCollection egc = gf.CreateGeometryCollection(Array.Empty()); + egc.SRID = 4326; + var convGc = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyCollection), egc.SRID); + Assert.True(convGc.Convert(egc).IsEmpty); + } + + [Fact] + public void Convert_NullGeometry_Throws_ArgumentNull() + { + var conv = SpatialConverter.For(Edm(EdmPrimitiveTypeKind.GeographyPoint), 4326); + Assert.Throws(() => conv.Convert((NtsGeometry)null)); + } + + [Fact] + public void For_NullPrimitiveType_Throws_ArgumentNullException() + { + // Act + var ex = Assert.Throws(() => SpatialConverter.For((IEdmPrimitiveTypeReference)null!, 4326)); + + // Assert + Assert.Equal("primitiveType", ex.ParamName); + } + + [Fact] + public void For_NonSpatialPrimitive_Throws_InvalidOperationException() + { + // Arrange: use a non-spatial EDM primitive kind (e.g., String) + IEdmPrimitiveTypeReference nonSpatial = EdmCoreModel.Instance.GetString(isNullable: false); + + // Act & Assert + Assert.Throws(() => SpatialConverter.For(nonSpatial, 0)); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationController.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationController.cs new file mode 100644 index 000000000..aa6905c30 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationController.cs @@ -0,0 +1,250 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using NetTopologySuite; +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Geography; + +public class SitesController : ODataController +{ + private readonly static GeometryFactory geographyFactory = NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326); + + private readonly static List sites = Generate(); + + // GET /Sites + [EnableQuery] + public ActionResult> Get() + { + return sites; + } + + // GET /Sites(1) + [EnableQuery] + public ActionResult Get(int key) + { + var site = sites.FirstOrDefault(s => s.Id == key); + if (site == null) + { + return NotFound(); + } + + return site; + } + + // GET /Sites(1)/Marker + [EnableQuery] + public ActionResult GetMarker(int key) + { + var site = sites.FirstOrDefault(s => s.Id == key); + if (site == null || site.Marker == null) + { + return NotFound(); + } + + return site.Marker; + } + + // GET /Sites(1)/Route + [EnableQuery] + public ActionResult GetRoute(int key) + { + var site = sites.FirstOrDefault(s => s.Id == key); + if (site == null || site.Route == null) + { + return NotFound(); + } + + return site.Route; + } + + // GET /Sites(1)/Park + [EnableQuery] + public ActionResult GetPark(int key) + { + var site = sites.FirstOrDefault(s => s.Id == key); + if (site == null || site.Park == null) + { + return NotFound(); + } + + return site.Park; + } + + // GET /Sites(1)/Markers + [EnableQuery] + public ActionResult GetMarkers(int key) + { + var site = sites.FirstOrDefault(s => s.Id == key); + if (site == null || site.Markers == null) + { + return NotFound(); + } + + return site.Markers; + } + + // GET /Sites(1)/Routes + [EnableQuery] + public ActionResult GetRoutes(int key) + { + var site = sites.FirstOrDefault(s => s.Id == key); + if (site == null || site.Routes == null) + { + return NotFound(); + } + + return site.Routes; + } + + // GET /Sites(1)/Parks + [EnableQuery] + public ActionResult GetParks(int key) + { + var site = sites.FirstOrDefault(s => s.Id == key); + if (site == null || site.Parks == null) + { + return NotFound(); + } + + return site.Parks; + } + + // GET /Sites(1)/Features + [EnableQuery] + public ActionResult GetFeatures(int key) + { + var site = sites.FirstOrDefault(s => s.Id == key); + if (site == null || site.Features == null) + { + return NotFound(); + } + + return site.Features; + } + + private static List Generate() + { + var point = geographyFactory.CreatePoint(new Coordinate(37.30750, -0.15083)); + + var lineString = geographyFactory.CreateLineString( + [ + new Coordinate(37.30750, -0.15083), + new Coordinate(37.32890, -0.1647) + ]); + + var polygon = geographyFactory.CreatePolygon(new LinearRing( + [ + new Coordinate(37.0000, 0.1010), // (lon, lat) NW + new Coordinate(37.0020, 0.1010), // NE + new Coordinate(37.0020, 0.0990), // SE + new Coordinate(37.0000, 0.0990), // SW + new Coordinate(37.0000, 0.1010) // back to NW to close + ]), + [ + new LinearRing( + [ + new Coordinate(37.0014, 0.1006), + new Coordinate(37.0016, 0.1002), + new Coordinate(37.0010, 0.1002), + new Coordinate(37.0014, 0.1006) // close + ]), + new LinearRing( + [ + new Coordinate(37.0015, 0.1007), + new Coordinate(37.0017, 0.1005), + new Coordinate(37.0015, 0.1003), + new Coordinate(37.0013, 0.1005), + new Coordinate(37.0015, 0.1007) // close + ]) + ]); + + var multiPoint = geographyFactory.CreateMultiPoint([point]); + var multiLineString = geographyFactory.CreateMultiLineString([lineString]); + var multiPolygon = geographyFactory.CreateMultiPolygon([polygon]); + + return new List + { + new Site + { + Id = 1, + Marker = point, + Route = lineString, + Park = polygon, + Markers = multiPoint, + Routes = multiLineString, + Parks = multiPolygon, + Features = geographyFactory.CreateGeometryCollection([point, lineString, polygon, multiPoint, multiLineString, multiPolygon]) + } + }; + } +} + +public class WarehousesController : ODataController +{ + private readonly static GeometryFactory geographyFactory = NtsGeometryServices.Instance.CreateGeometryFactory(srid: 4326); + + private readonly List parks = new List + { + new Warehouse + { + Id = 1, + Location = geographyFactory.CreatePoint(new Coordinate(37.30750, -0.15083)), + Route = geographyFactory.CreateLineString(new[] + { + new Coordinate(37.30750, -0.15083), + new Coordinate(37.32890, -0.1647) + }) + } + }; + + // GET /Warehouses + [EnableQuery] + public ActionResult> Get() + { + return parks; + } + + // GET /Warehouses(1) + [EnableQuery] + public ActionResult Get(int key) + { + var warehouse = parks.FirstOrDefault(p => p.Id == key); + if (warehouse == null) + { + return NotFound(); + } + + return warehouse; + } + + // GET /Warehouses(1)/Location + public ActionResult GetLocation(int key) + { + var warehouse = parks.FirstOrDefault(p => p.Id == key); + if (warehouse == null || warehouse.Location == null) + { + return NotFound(); + } + + return warehouse.Location; + } + + // GET /Warehouses(1)/Route + public ActionResult GetRoute(int key) + { + var warehouse = parks.FirstOrDefault(p => p.Id == key); + if (warehouse == null || warehouse.Route == null) + { + return NotFound(); + } + return warehouse.Route; + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationDataModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationDataModel.cs new file mode 100644 index 000000000..a10e8b345 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationDataModel.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Attributes; +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Geography; + +public class Site +{ + public int Id { get; set; } + [Geography] + public Point Marker { get; set; } + [Geography] + public LineString Route { get; set; } + [Geography] + public Polygon Park { get; set; } + [Geography] + public MultiPoint Markers { get; set; } + [Geography] + public MultiLineString Routes { get; set; } + [Geography] + public MultiPolygon Parks { get; set; } + [Geography] + public GeometryCollection Features { get; set; } +} + +[Geography] +public class Warehouse +{ + public int Id { get; set; } + public Point Location { get; set; } + public LineString Route { get; set; } +} + +public class SqlSite +{ + public int Id { get; set; } + public Point Marker { get; set; } + public LineString Route { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationEdmModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationEdmModel.cs new file mode 100644 index 000000000..bb6a127a0 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationEdmModel.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Geography; + +public class GeographySerializationEdmModel +{ + public static IEdmModel GetEdmModel() + { + var modelBuilder = new ODataConventionModelBuilder() + .UseNetTopologySuite(); + + modelBuilder.EntitySet("Sites"); + modelBuilder.EntitySet("Warehouses"); + + var model = modelBuilder.GetEdmModel() + .UseNetTopologySuite(); + + return model; + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationTests.cs new file mode 100644 index 000000000..7b41b4d73 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geography/GeographySerializationTests.cs @@ -0,0 +1,361 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Geography; + +public class GeographySerializationTests : WebApiTestBase +{ + public GeographySerializationTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = GeographySerializationEdmModel.GetEdmModel(); + + services.ConfigureControllers( + typeof(SitesController), typeof(WarehousesController)); + + services.AddControllers().AddOData( + options => + options.EnableQueryFeatures().AddRouteComponents( + routePrefix: string.Empty, + model: model, + configureServices: (services) => services.AddODataNetTopologySuite())); + } + + protected static void UpdateConfigure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + [Fact] + public async Task GeographyPoint_Serialization_Succeeds_As_GeoJson_With_CRS4326Async() + { + // Arrange + var queryUrl = $"/Sites(1)/Marker"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + // Validate GeoJSON shape + Assert.Equal("Point", geo.GetProperty("type").GetString()); + + var coords = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, coords.Length); + + // GeoJSON uses [lon, lat] ordering. + Assert.Equal(37.30750, coords[0].GetDouble(), 5); // lon (X) + Assert.Equal(-0.15083, coords[1].GetDouble(), 5); // lat (Y) + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeographyLineString_Serialization_Returns_GeoJson_With_CRS4326() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Sites(1)/Route"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + Assert.Equal("LineString", geo.GetProperty("type").GetString()); + + var coords = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, coords.Length); + + var p0 = coords[0].EnumerateArray().ToArray(); + var p1 = coords[1].EnumerateArray().ToArray(); + Assert.Equal(37.30750, p0[0].GetDouble(), 5); + Assert.Equal(-0.15083, p0[1].GetDouble(), 5); + Assert.Equal(37.32890, p1[0].GetDouble(), 5); + Assert.Equal(-0.1647, p1[1].GetDouble(), 4); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeographyPolygon_Serialization_Returns_GeoJson_With_CRS4326() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Sites(1)/Park"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + Assert.Equal("Polygon", geo.GetProperty("type").GetString()); + + // coordinates: [ outerRing[], hole1[], hole2[] ] + var rings = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(3, rings.Length); + + // Outer ring first two positions + var outer = rings[0].EnumerateArray().ToArray(); + var v0 = outer[0].EnumerateArray().ToArray(); + var v1 = outer[1].EnumerateArray().ToArray(); + Assert.Equal(37.0000, v0[0].GetDouble(), 4); + Assert.Equal(0.1010, v0[1].GetDouble(), 4); + Assert.Equal(37.0020, v1[0].GetDouble(), 4); + Assert.Equal(0.1010, v1[1].GetDouble(), 4); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeographyMultiPoint_Serialization_Returns_GeoJson_With_CRS4326() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Sites(1)/Markers"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + Assert.Equal("MultiPoint", geo.GetProperty("type").GetString()); + + var coords = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Single(coords); + var p0 = coords[0].EnumerateArray().ToArray(); + Assert.Equal(37.30750, p0[0].GetDouble(), 5); + Assert.Equal(-0.15083, p0[1].GetDouble(), 5); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeographyMultiLineString_Serialization_Returns_GeoJson_With_CRS4326() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Sites(1)/Routes"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + Assert.Equal("MultiLineString", geo.GetProperty("type").GetString()); + + // coordinates: [ lineString[] ] + var lines = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Single(lines); + + var line = lines[0].EnumerateArray().ToArray(); + Assert.Equal(2, line.Length); + + var p0 = line[0].EnumerateArray().ToArray(); + var p1 = line[1].EnumerateArray().ToArray(); + Assert.Equal(37.30750, p0[0].GetDouble(), 5); + Assert.Equal(-0.15083, p0[1].GetDouble(), 5); + Assert.Equal(37.32890, p1[0].GetDouble(), 5); + Assert.Equal(-0.1647, p1[1].GetDouble(), 4); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeographyMultiPolygon_Serialization_Returns_GeoJson_With_CRS4326() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Sites(1)/Parks"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + Assert.Equal("MultiPolygon", geo.GetProperty("type").GetString()); + + // coordinates: [ polygon[] ] + var polys = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Single(polys); + + // first polygon rings + var rings = polys[0].EnumerateArray().ToArray(); + Assert.Equal(3, rings.Length); // outer + 2 holes + + var outer = rings[0].EnumerateArray().ToArray(); + var v0 = outer[0].EnumerateArray().ToArray(); + Assert.Equal(37.0000, v0[0].GetDouble(), 4); + Assert.Equal(0.1010, v0[1].GetDouble(), 4); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeographyGeometryCollection_Serialization_Returns_GeoJson_With_CRS4326() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Sites(1)/Features"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + Assert.Equal("GeometryCollection", geo.GetProperty("type").GetString()); + + var geoms = geo.GetProperty("geometries").EnumerateArray().ToArray(); + Assert.Equal(6, geoms.Length); + + Assert.Equal("Point", geoms[0].GetProperty("type").GetString()); + Assert.Equal("LineString", geoms[1].GetProperty("type").GetString()); + Assert.Equal("Polygon", geoms[2].GetProperty("type").GetString()); + Assert.Equal("MultiPoint", geoms[3].GetProperty("type").GetString()); + Assert.Equal("MultiLineString", geoms[4].GetProperty("type").GetString()); + Assert.Equal("MultiPolygon", geoms[5].GetProperty("type").GetString()); + + // Spot-check first point coords + var p = geoms[0].GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(37.30750, p[0].GetDouble(), 5); + Assert.Equal(-0.15083, p[1].GetDouble(), 5); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeographyPoint_Serialization_Succeeds_ForGeographyAttributeOnTypeAsync() + { + // Arrange + var queryUrl = $"/Warehouses(1)/Location"; + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var result = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + // Validate GeoJSON shape + Assert.Equal("Point", geo.GetProperty("type").GetString()); + + var coords = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, coords.Length); + + // GeoJSON uses [lon, lat] ordering. + Assert.Equal(37.30750, coords[0].GetDouble(), 5); // lon (X) + Assert.Equal(-0.15083, coords[1].GetDouble(), 5); // lat (Y) + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeographyLineString_Serialization_Succeeds_ForGeographyAttributeOnTypeAsync() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Warehouses(1)/Route"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(result); + var root = doc.RootElement; + var geo = root.TryGetProperty("value", out var valueElement) ? valueElement : root; + + Assert.Equal("LineString", geo.GetProperty("type").GetString()); + + var coords = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, coords.Length); + + var p0 = coords[0].EnumerateArray().ToArray(); + var p1 = coords[1].EnumerateArray().ToArray(); + Assert.Equal(37.30750, p0[0].GetDouble(), 5); + Assert.Equal(-0.15083, p0[1].GetDouble(), 5); + Assert.Equal(37.32890, p1[0].GetDouble(), 5); + Assert.Equal(-0.1647, p1[1].GetDouble(), 4); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("4326", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationController.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationController.cs new file mode 100644 index 000000000..00eee762f --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationController.cs @@ -0,0 +1,190 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using NetTopologySuite; +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Geometry; + +public class PlantsController : ODataController +{ + private readonly static GeometryFactory geometryFactory = NtsGeometryServices.Instance.CreateGeometryFactory(srid: 0); + + private readonly static List plants = Generate(); + + // GET /Plants + [EnableQuery] + public ActionResult> Get() + { + return plants; + } + + // GET /Plants(1) + [EnableQuery] + public ActionResult Get(int key) + { + var plant = plants.FirstOrDefault(s => s.Id == key); + if (plant == null) + { + return NotFound(); + } + + return plant; + } + + // GET /Plants(1)/Location + [EnableQuery] + public ActionResult GetLocation(int key) + { + var plant = plants.FirstOrDefault(s => s.Id == key); + if (plant == null || plant.Location == null) + { + return NotFound(); + } + + return plant.Location; + } + + // GET /Plants(1)/Track + [EnableQuery] + public ActionResult GetTrack(int key) + { + var plant = plants.FirstOrDefault(s => s.Id == key); + if (plant == null || plant.Track == null) + { + return NotFound(); + } + + return plant.Track; + } + + // GET /Plants(1)/Zone + [EnableQuery] + public ActionResult GetZone(int key) + { + var plant = plants.FirstOrDefault(s => s.Id == key); + if (plant == null || plant.Zone == null) + { + return NotFound(); + } + + return plant.Zone; + } + + // GET /Plants(1)/Locations + [EnableQuery] + public ActionResult GetLocations(int key) + { + var plant = plants.FirstOrDefault(s => s.Id == key); + if (plant == null || plant.Locations == null) + { + return NotFound(); + } + + return plant.Locations; + } + + // GET /Plants(1)/Tracks + [EnableQuery] + public ActionResult GetTracks(int key) + { + var plant = plants.FirstOrDefault(s => s.Id == key); + if (plant == null || plant.Tracks == null) + { + return NotFound(); + } + + return plant.Tracks; + } + + // GET /Plants(1)/Zones + [EnableQuery] + public ActionResult GetZones(int key) + { + var plant = plants.FirstOrDefault(s => s.Id == key); + if (plant == null || plant.Zones == null) + { + return NotFound(); + } + + return plant.Zones; + } + + // GET /Plants(1)/Layout + [EnableQuery] + public ActionResult GetLayout(int key) + { + var plant = plants.FirstOrDefault(s => s.Id == key); + if (plant == null || plant.Layout == null) + { + return NotFound(); + } + + return plant.Layout; + } + + public static List Generate() + { + var point = geometryFactory.CreatePoint(new Coordinate(2, 2)); + + var lineString = geometryFactory.CreateLineString( + [ + new Coordinate(-2, 4), + new Coordinate(12, 6) + ]); + + var polygon = geometryFactory.CreatePolygon(new LinearRing( + [ + new Coordinate(0, 10), // NW + new Coordinate(10, 10), // NE + new Coordinate(10, 0), // SE + new Coordinate(0, 0), // SW + new Coordinate(0, 10) // close + ]), + [ + new LinearRing( + [ + new Coordinate(3, 5), + new Coordinate(5, 5), + new Coordinate(5, 3), + new Coordinate(3, 3), + new Coordinate(3, 5) // close + ]), + new LinearRing( + [ + new Coordinate(7, 3), + new Coordinate(9, 3), + new Coordinate(9, 1), + new Coordinate(7, 1), + new Coordinate(7, 3) // close + ]) + ]); + + var multiPoint = geometryFactory.CreateMultiPoint([point]); + var multiLineString = geometryFactory.CreateMultiLineString([lineString]); + var multiPolygon = geometryFactory.CreateMultiPolygon([polygon]); + + return new List + { + new Plant + { + Id = 1, + Location = point, + Track = lineString, + Zone = polygon, + Locations = multiPoint, + Tracks = multiLineString, + Zones = multiPolygon, + Layout = geometryFactory.CreateGeometryCollection([point, lineString, polygon, multiPoint, multiLineString, multiPolygon]) + } + }; + } + +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationDataModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationDataModel.cs new file mode 100644 index 000000000..cf56f33de --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationDataModel.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using NetTopologySuite.Geometries; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Geometry; + +public class Plant +{ + public int Id { get; set; } + public Point Location { get; set; } + public LineString Track { get; set; } + public Polygon Zone { get; set; } + public MultiPoint Locations { get; set; } + public MultiLineString Tracks { get; set; } + public MultiPolygon Zones { get; set; } + public GeometryCollection Layout { get; set; } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationEdmModel.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationEdmModel.cs new file mode 100644 index 000000000..9b6523b2e --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationEdmModel.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Geometry; + +internal class GeometrySerializationEdmModel +{ + public static IEdmModel GetEdmModel() + { + var modelBuilder = new ODataConventionModelBuilder() + .UseNetTopologySuite(); + + modelBuilder.EntitySet("Plants"); + + var model = modelBuilder.GetEdmModel() + .UseNetTopologySuite(); + + return model; + } +} diff --git a/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationTests.cs b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationTests.cs new file mode 100644 index 000000000..0006905c9 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.NetTopologySuite.Tests/Serialization/Geometry/GeometrySerializationTests.cs @@ -0,0 +1,271 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.NetTopologySuite.Extensions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.OData.NetTopologySuite.Tests.Serialization.Geometry; + +public class GeometrySerializationTests : WebApiTestBase +{ + public GeometrySerializationTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = GeometrySerializationEdmModel.GetEdmModel(); + + services.ConfigureControllers( + typeof(PlantsController)); + + services.AddControllers().AddOData( + options => + options.EnableQueryFeatures().AddRouteComponents( + routePrefix: string.Empty, + model: model, + configureServices: (services) => services.AddODataNetTopologySuite())); + } + + protected static void UpdateConfigure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + [Fact] + public async Task GeometryPoint_Serialization_Returns_GeoJson_With_CRS0() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Plants(1)/Location"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var geo = root.TryGetProperty("value", out var valueEl) ? valueEl : root; + + Assert.Equal("Point", geo.GetProperty("type").GetString()); + + var coords = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, coords.Length); + Assert.Equal(2.0, coords[0].GetDouble(), 6); + Assert.Equal(2.0, coords[1].GetDouble(), 6); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeometryLineString_Serialization_Returns_GeoJson_With_CRS0() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Plants(1)/Track"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var geo = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + Assert.Equal("LineString", geo.GetProperty("type").GetString()); + + var coords = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2, coords.Length); + + var p0 = coords[0].EnumerateArray().ToArray(); + var p1 = coords[1].EnumerateArray().ToArray(); + Assert.Equal(-2.0, p0[0].GetDouble(), 6); + Assert.Equal(4.0, p0[1].GetDouble(), 6); + Assert.Equal(12.0, p1[0].GetDouble(), 6); + Assert.Equal(6.0, p1[1].GetDouble(), 6); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeometryPolygon_Serialization_Returns_GeoJson_With_CRS0() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Plants(1)/Zone"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var geo = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + Assert.Equal("Polygon", geo.GetProperty("type").GetString()); + + var rings = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(3, rings.Length); // outer + 2 holes + + // Outer ring first two points + var outer = rings[0].EnumerateArray().ToArray(); + var v0 = outer[0].EnumerateArray().ToArray(); + var v1 = outer[1].EnumerateArray().ToArray(); + Assert.Equal(0.0, v0[0].GetDouble(), 6); + Assert.Equal(10.0, v0[1].GetDouble(), 6); + Assert.Equal(10.0, v1[0].GetDouble(), 6); + Assert.Equal(10.0, v1[1].GetDouble(), 6); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeometryMultiPoint_Serialization_Returns_GeoJson_With_CRS0() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Plants(1)/Locations"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var geo = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + Assert.Equal("MultiPoint", geo.GetProperty("type").GetString()); + + var coords = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Single(coords); + + var p0 = coords[0].EnumerateArray().ToArray(); + Assert.Equal(2.0, p0[0].GetDouble(), 6); + Assert.Equal(2.0, p0[1].GetDouble(), 6); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeometryMultiLineString_Serialization_Returns_GeoJson_With_CRS0() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Plants(1)/Tracks"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var geo = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + Assert.Equal("MultiLineString", geo.GetProperty("type").GetString()); + + var lines = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Single(lines); + + var line = lines[0].EnumerateArray().ToArray(); + Assert.Equal(2, line.Length); + + var p0 = line[0].EnumerateArray().ToArray(); + var p1 = line[1].EnumerateArray().ToArray(); + Assert.Equal(-2.0, p0[0].GetDouble(), 6); + Assert.Equal(4.0, p0[1].GetDouble(), 6); + Assert.Equal(12.0, p1[0].GetDouble(), 6); + Assert.Equal(6.0, p1[1].GetDouble(), 6); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeometryMultiPolygon_Serialization_Returns_GeoJson_With_CRS0() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Plants(1)/Zones"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var geo = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + Assert.Equal("MultiPolygon", geo.GetProperty("type").GetString()); + + var polys = geo.GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Single(polys); + + var rings = polys[0].EnumerateArray().ToArray(); + Assert.Equal(3, rings.Length); + + var outer = rings[0].EnumerateArray().ToArray(); + var v0 = outer[0].EnumerateArray().ToArray(); + Assert.Equal(0.0, v0[0].GetDouble(), 6); + Assert.Equal(10.0, v0[1].GetDouble(), 6); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GeometryCollection_Serialization_Returns_GeoJson_With_CRS0() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/Plants(1)/Layout"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=minimal")); + var client = CreateClient(); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var geo = (doc.RootElement.TryGetProperty("value", out var v) ? v : doc.RootElement); + + Assert.Equal("GeometryCollection", geo.GetProperty("type").GetString()); + + var geoms = geo.GetProperty("geometries").EnumerateArray().ToArray(); + Assert.Equal(6, geoms.Length); + + Assert.Equal("Point", geoms[0].GetProperty("type").GetString()); + Assert.Equal("LineString", geoms[1].GetProperty("type").GetString()); + Assert.Equal("Polygon", geoms[2].GetProperty("type").GetString()); + Assert.Equal("MultiPoint", geoms[3].GetProperty("type").GetString()); + Assert.Equal("MultiLineString", geoms[4].GetProperty("type").GetString()); + Assert.Equal("MultiPolygon", geoms[5].GetProperty("type").GetString()); + + var p = geoms[0].GetProperty("coordinates").EnumerateArray().ToArray(); + Assert.Equal(2.0, p[0].GetDouble(), 6); + Assert.Equal(2.0, p[1].GetDouble(), 6); + + var crs = geo.GetProperty("crs"); + Assert.Equal("name", crs.GetProperty("type").GetString(), StringComparer.OrdinalIgnoreCase); + Assert.Contains("0", crs.GetProperty("properties").GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/test/Microsoft.AspNetCore.OData.TestCommon/Microsoft.AspNetCore.OData.TestCommon.csproj b/test/Microsoft.AspNetCore.OData.TestCommon/Microsoft.AspNetCore.OData.TestCommon.csproj index cbec46276..93bf44010 100644 --- a/test/Microsoft.AspNetCore.OData.TestCommon/Microsoft.AspNetCore.OData.TestCommon.csproj +++ b/test/Microsoft.AspNetCore.OData.TestCommon/Microsoft.AspNetCore.OData.TestCommon.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 Microsoft.AspNetCore.OData.TestCommon Microsoft.AspNetCore.OData.TestCommon