diff --git a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs index 85e3714737f..99233db8e21 100644 --- a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs +++ b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs @@ -610,7 +610,7 @@ private static void AppendLiteral(StoreObjectIdentifier storeObject, IndentedStr .Append("DbFunction(").Append(code.Literal(storeObject.Name)).Append(")"); break; default: - Check.DebugAssert(false, "Unexpected StoreObjectType: " + storeObject.StoreObjectType); + Check.DebugFail("Unexpected StoreObjectType: " + storeObject.StoreObjectType); break; } } diff --git a/src/EFCore.Relational/Infrastructure/EntityFrameworkRelationalServicesBuilder.cs b/src/EFCore.Relational/Infrastructure/EntityFrameworkRelationalServicesBuilder.cs index a30b3485fb7..a20a9fca65f 100644 --- a/src/EFCore.Relational/Infrastructure/EntityFrameworkRelationalServicesBuilder.cs +++ b/src/EFCore.Relational/Infrastructure/EntityFrameworkRelationalServicesBuilder.cs @@ -90,6 +90,7 @@ public static readonly IDictionary RelationalServi new ServiceCharacteristics(ServiceLifetime.Singleton, multipleRegistrations: true) }, { typeof(IMethodCallTranslatorPlugin), new ServiceCharacteristics(ServiceLifetime.Scoped, multipleRegistrations: true) }, + { typeof(IAggregateMethodCallTranslatorPlugin), new ServiceCharacteristics(ServiceLifetime.Scoped, multipleRegistrations: true) }, { typeof(IMemberTranslatorPlugin), new ServiceCharacteristics(ServiceLifetime.Scoped, multipleRegistrations: true) } }; diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index 941add0b3ad..dc97a4c75b4 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -237,7 +237,7 @@ protected virtual IReadOnlyList Sort( else { leftovers.Add(operation); - Check.DebugAssert(false, "Unexpected operation type: " + operation.GetType()); + Check.DebugFail("Unexpected operation type: " + operation.GetType()); } } @@ -275,7 +275,7 @@ protected virtual IReadOnlyList Sort( } else { - Check.DebugAssert(false, "Operation removed twice: " + cyclicAddForeignKeyOperation); + Check.DebugFail("Operation removed twice: " + cyclicAddForeignKeyOperation); } } @@ -2189,7 +2189,7 @@ private IEnumerable GetDataOperations( if (forSource) { - Check.DebugAssert(false, "Insert using the source model"); + Check.DebugFail("Insert using the source model"); break; } @@ -2212,7 +2212,7 @@ private IEnumerable GetDataOperations( if (forSource) { - Check.DebugAssert(false, "Update using the source model"); + Check.DebugFail("Update using the source model"); break; } diff --git a/src/EFCore.Relational/Query/Internal/FromSqlParameterExpandingExpressionVisitor.cs b/src/EFCore.Relational/Query/Internal/FromSqlParameterExpandingExpressionVisitor.cs index a90e59798cf..78a51e11b72 100644 --- a/src/EFCore.Relational/Query/Internal/FromSqlParameterExpandingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/Internal/FromSqlParameterExpandingExpressionVisitor.cs @@ -160,7 +160,7 @@ public virtual Expression Expand( return _visitedFromSqlExpressions[fromSql] = fromSql.Update(Expression.Constant(constantValues, typeof(object[]))); default: - Check.DebugAssert(false, "FromSql.Arguments must be Constant/ParameterExpression"); + Check.DebugFail("FromSql.Arguments must be Constant/ParameterExpression"); return null; } } diff --git a/src/EFCore.SqlServer.NTS/Extensions/SqlServerNetTopologySuiteServiceCollectionExtensions.cs b/src/EFCore.SqlServer.NTS/Extensions/SqlServerNetTopologySuiteServiceCollectionExtensions.cs index 802a8e982d1..670d9dbd1af 100644 --- a/src/EFCore.SqlServer.NTS/Extensions/SqlServerNetTopologySuiteServiceCollectionExtensions.cs +++ b/src/EFCore.SqlServer.NTS/Extensions/SqlServerNetTopologySuiteServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ public static IServiceCollection AddEntityFrameworkSqlServerNetTopologySuite( new EntityFrameworkRelationalServicesBuilder(serviceCollection) .TryAdd() .TryAdd() + .TryAdd() .TryAdd() .TryAdd(); diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMethodTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMethodTranslator.cs index 4ddf7d2abf4..d99347b95e0 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMethodTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMethodTranslator.cs @@ -85,7 +85,12 @@ public SqlServerGeometryMethodTranslator( arguments.Where(e => typeof(Geometry).IsAssignableFrom(e.Type))); var typeMapping = ExpressionExtensions.InferTypeMapping(geometryExpressions.ToArray()); - Check.DebugAssert(typeMapping != null, "At least one argument must have typeMapping."); + if (typeMapping is null) + { + Check.DebugFail("At least one argument must have typeMapping."); + return null; + } + var storeType = typeMapping.StoreType; var isGeography = string.Equals(storeType, "geography", StringComparison.OrdinalIgnoreCase); diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerNetTopologySuiteAggregateMethodCallTranslatorPlugin.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerNetTopologySuiteAggregateMethodCallTranslatorPlugin.cs new file mode 100644 index 00000000000..7447c7f2bdd --- /dev/null +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerNetTopologySuiteAggregateMethodCallTranslatorPlugin.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class SqlServerNetTopologySuiteAggregateMethodCallTranslatorPlugin : IAggregateMethodCallTranslatorPlugin +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SqlServerNetTopologySuiteAggregateMethodCallTranslatorPlugin( + IRelationalTypeMappingSource typeMappingSource, + ISqlExpressionFactory sqlExpressionFactory) + { + Translators = new IAggregateMethodCallTranslator[] + { + new SqlServerNetTopologySuiteAggregateMethodTranslator(sqlExpressionFactory, typeMappingSource) + }; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual IEnumerable Translators { get; } +} diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerNetTopologySuiteAggregateMethodTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerNetTopologySuiteAggregateMethodTranslator.cs new file mode 100644 index 00000000000..0d9328191cb --- /dev/null +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerNetTopologySuiteAggregateMethodTranslator.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using NetTopologySuite.Algorithm; +using NetTopologySuite.Geometries; +using NetTopologySuite.Geometries.Utilities; +using NetTopologySuite.Operation.Union; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class SqlServerNetTopologySuiteAggregateMethodTranslator : IAggregateMethodCallTranslator +{ + private static readonly MethodInfo GeometryCombineMethod + = typeof(GeometryCombiner).GetRuntimeMethod(nameof(GeometryCombiner.Combine), new[] { typeof(IEnumerable) })!; + + private static readonly MethodInfo ConvexHullMethod + = typeof(ConvexHull).GetRuntimeMethod(nameof(ConvexHull.Create), new[] { typeof(IEnumerable) })!; + + private static readonly MethodInfo UnionMethod + = typeof(UnaryUnionOp).GetRuntimeMethod(nameof(UnaryUnionOp.Union), new[] { typeof(IEnumerable) })!; + + private readonly ISqlExpressionFactory _sqlExpressionFactory; + private readonly IRelationalTypeMappingSource _typeMappingSource; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SqlServerNetTopologySuiteAggregateMethodTranslator( + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource) + { + _sqlExpressionFactory = sqlExpressionFactory; + _typeMappingSource = typeMappingSource; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual SqlExpression? Translate( + MethodInfo method, EnumerableExpression source, IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + // Docs: https://docs.microsoft.com/sql/t-sql/spatial-geometry/static-aggregate-geometry-methods + + if (source.Selector is not SqlExpression sqlExpression) + { + return null; + } + + if (sqlExpression.TypeMapping is not { } typeMapping) + { + Check.DebugFail("SQL expression is missing a type mapping."); + return null; + } + + var functionName = method == GeometryCombineMethod + ? "CollectionAggregate" + : method == UnionMethod + ? "UnionAggregate" + : method == ConvexHullMethod + ? "ConvexHullAggregate" + : null; + + if (functionName is null) + { + return null; + } + + if (source.Predicate != null) + { + sqlExpression = _sqlExpressionFactory.Case( + new List { new(source.Predicate, sqlExpression) }, + elseResult: null); + } + + if (source.IsDistinct) + { + sqlExpression = new DistinctExpression(sqlExpression); + } + + return _sqlExpressionFactory.Function( + $"{typeMapping.StoreType}::{functionName}", + new[] { sqlExpression }, + nullable: true, + argumentsPropagateNullability: new[] { false }, + method.ReturnType, + _typeMappingSource.FindMapping(method.ReturnType, typeMapping.StoreType)); + } +} diff --git a/src/EFCore.Sqlite.NTS/Extensions/SqliteNetTopologySuiteServiceCollectionExtensions.cs b/src/EFCore.Sqlite.NTS/Extensions/SqliteNetTopologySuiteServiceCollectionExtensions.cs index d0d9f50829b..7c9566c91eb 100644 --- a/src/EFCore.Sqlite.NTS/Extensions/SqliteNetTopologySuiteServiceCollectionExtensions.cs +++ b/src/EFCore.Sqlite.NTS/Extensions/SqliteNetTopologySuiteServiceCollectionExtensions.cs @@ -30,6 +30,7 @@ public static IServiceCollection AddEntityFrameworkSqliteNetTopologySuite( new EntityFrameworkRelationalServicesBuilder(serviceCollection) .TryAdd() .TryAdd() + .TryAdd() .TryAdd(); return serviceCollection; diff --git a/src/EFCore.Sqlite.NTS/Query/Internal/SqliteNetTopologySuiteAggregateMethodCallTranslatorPlugin.cs b/src/EFCore.Sqlite.NTS/Query/Internal/SqliteNetTopologySuiteAggregateMethodCallTranslatorPlugin.cs new file mode 100644 index 00000000000..b451a8ba6a6 --- /dev/null +++ b/src/EFCore.Sqlite.NTS/Query/Internal/SqliteNetTopologySuiteAggregateMethodCallTranslatorPlugin.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Sqlite.Query.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class SqliteNetTopologySuiteAggregateMethodCallTranslatorPlugin : IAggregateMethodCallTranslatorPlugin +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SqliteNetTopologySuiteAggregateMethodCallTranslatorPlugin( + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource) + { + Translators = new IAggregateMethodCallTranslator[] + { + new SqliteNetTopologySuiteAggregateMethodTranslator(sqlExpressionFactory, typeMappingSource) + }; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual IEnumerable Translators { get; } +} diff --git a/src/EFCore.Sqlite.NTS/Query/Internal/SqliteNetTopologySuiteAggregateMethodTranslator.cs b/src/EFCore.Sqlite.NTS/Query/Internal/SqliteNetTopologySuiteAggregateMethodTranslator.cs new file mode 100644 index 00000000000..598ac741138 --- /dev/null +++ b/src/EFCore.Sqlite.NTS/Query/Internal/SqliteNetTopologySuiteAggregateMethodTranslator.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using NetTopologySuite.Algorithm; +using NetTopologySuite.Geometries; +using NetTopologySuite.Geometries.Utilities; +using NetTopologySuite.Operation.Union; + +namespace Microsoft.EntityFrameworkCore.Sqlite.Query.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class SqliteNetTopologySuiteAggregateMethodTranslator : IAggregateMethodCallTranslator +{ + private static readonly MethodInfo GeometryCombineMethod + = typeof(GeometryCombiner).GetRuntimeMethod(nameof(GeometryCombiner.Combine), new[] { typeof(IEnumerable) })!; + + private static readonly MethodInfo ConvexHullMethod + = typeof(ConvexHull).GetRuntimeMethod(nameof(ConvexHull.Create), new[] { typeof(IEnumerable) })!; + + private static readonly MethodInfo UnionMethod + = typeof(UnaryUnionOp).GetRuntimeMethod(nameof(UnaryUnionOp.Union), new[] { typeof(IEnumerable) })!; + + private readonly ISqlExpressionFactory _sqlExpressionFactory; + private readonly IRelationalTypeMappingSource _typeMappingSource; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SqliteNetTopologySuiteAggregateMethodTranslator( + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource) + { + _sqlExpressionFactory = sqlExpressionFactory; + _typeMappingSource = typeMappingSource; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual SqlExpression? Translate( + MethodInfo method, EnumerableExpression source, IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (source.Selector is not SqlExpression sqlExpression + || (method != GeometryCombineMethod && method != UnionMethod && method != ConvexHullMethod)) + { + return null; + } + + if (source.Predicate != null) + { + sqlExpression = _sqlExpressionFactory.Case( + new List { new(source.Predicate, sqlExpression) }, + elseResult: null); + } + + if (source.IsDistinct) + { + sqlExpression = new DistinctExpression(sqlExpression); + } + + if (method == GeometryCombineMethod || method == UnionMethod) + { + return _sqlExpressionFactory.Function( + method == GeometryCombineMethod ? "Collect" : "GUnion", + new[] { sqlExpression }, + nullable: true, + argumentsPropagateNullability: new[] { false }, + typeof(Geometry)); + } + + // Spatialite has no built-in aggregate convex hull, but we can simply apply Collect beforehand + return _sqlExpressionFactory.Function( + "ConvexHull", + new[] + { + _sqlExpressionFactory.Function( + "Collect", + new[] { sqlExpression }, + nullable: true, + argumentsPropagateNullability: new[] { false }, + typeof(Geometry)) + }, + nullable: true, + argumentsPropagateNullability: new[] { true }, + typeof(Geometry)); + } +} diff --git a/src/EFCore/Infrastructure/ModelValidator.cs b/src/EFCore/Infrastructure/ModelValidator.cs index 30f82089f18..69e622a108b 100644 --- a/src/EFCore/Infrastructure/ModelValidator.cs +++ b/src/EFCore/Infrastructure/ModelValidator.cs @@ -330,7 +330,7 @@ protected virtual void ValidateIgnoredMembers( ignoredMember, entityType.DisplayName(), property.DeclaringEntityType.DisplayName())); } - Check.DebugAssert(false, "Should never get here..."); + Check.DebugFail("Should never get here..."); } var navigation = entityType.FindNavigation(ignoredMember); @@ -343,7 +343,7 @@ protected virtual void ValidateIgnoredMembers( ignoredMember, entityType.DisplayName(), navigation.DeclaringEntityType.DisplayName())); } - Check.DebugAssert(false, "Should never get here..."); + Check.DebugFail("Should never get here..."); } var skipNavigation = entityType.FindSkipNavigation(ignoredMember); @@ -356,7 +356,7 @@ protected virtual void ValidateIgnoredMembers( ignoredMember, entityType.DisplayName(), skipNavigation.DeclaringEntityType.DisplayName())); } - Check.DebugAssert(false, "Should never get here..."); + Check.DebugFail("Should never get here..."); } var serviceProperty = entityType.FindServiceProperty(ignoredMember); @@ -369,7 +369,7 @@ protected virtual void ValidateIgnoredMembers( ignoredMember, entityType.DisplayName(), serviceProperty.DeclaringEntityType.DisplayName())); } - Check.DebugAssert(false, "Should never get here..."); + Check.DebugFail("Should never get here..."); } } } diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs index 68ec5094ed8..c184c12b43d 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs @@ -59,7 +59,7 @@ public ImmediateConventionScope(ConventionSet conventionSet, ConventionDispatche } public override void Run(ConventionDispatcher dispatcher) - => Check.DebugAssert(false, "Immediate convention scope cannot be run again."); + => Check.DebugFail("Immediate convention scope cannot be run again."); public IConventionModelBuilder OnModelFinalizing(IConventionModelBuilder modelBuilder) { @@ -716,7 +716,7 @@ public IConventionModelBuilder OnModelInitialized(IConventionModelBuilder modelB if (navigationBuilder.Metadata.IsInModel && navigationBuilder.Metadata.FindAnnotation(name) != annotation) { - Check.DebugAssert(false, "annotation removed"); + Check.DebugFail("annotation removed"); return null; } diff --git a/src/Shared/Check.cs b/src/Shared/Check.cs index 43b670e39d1..cde8fc8c775 100644 --- a/src/Shared/Check.cs +++ b/src/Shared/Check.cs @@ -120,4 +120,9 @@ public static void DebugAssert([DoesNotReturnIf(false)] bool condition, string m throw new Exception($"Check.DebugAssert failed: {message}"); } } + + [Conditional("DEBUG")] + [DoesNotReturn] + public static void DebugFail(string message) + => throw new Exception($"Check.DebugFail failed: {message}"); } diff --git a/test/EFCore.Specification.Tests/Query/SpatialQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/SpatialQueryTestBase.cs index aae53435bd9..377771a78f9 100644 --- a/test/EFCore.Specification.Tests/Query/SpatialQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/SpatialQueryTestBase.cs @@ -3,6 +3,9 @@ using Microsoft.EntityFrameworkCore.TestModels.SpatialModel; using NetTopologySuite.Geometries; +using NetTopologySuite.Geometries.Utilities; +using NetTopologySuite.Operation.Polygonize; +using NetTopologySuite.Operation.Union; namespace Microsoft.EntityFrameworkCore.Query; @@ -179,6 +182,32 @@ public virtual Task Centroid(bool async) Assert.Equal(e.Centroid, a.Centroid, GeometryComparer.Instance); }); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Combine_aggregate(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(e => e.Point != null) + .GroupBy(e => e.Group) + .Select(g => new + { + Id = g.Key, + Combined = GeometryCombiner.Combine(g.Select(e => e.Point)) + }), + elementSorter: x => x.Id, + elementAsserter: (e, a) => + { + Assert.Equal(e.Id, a.Id); + + // Note that NTS returns a MultiPoint (which is a subclass of GeometryCollection), whereas SQL Server returns a + // GeometryCollection. + var eCollection = (GeometryCollection)e.Combined; + var aCollection = (GeometryCollection)a.Combined; + + Assert.Equal(eCollection.Geometries, aCollection.Geometries); + }); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Contains(bool async) @@ -207,6 +236,22 @@ public virtual Task ConvexHull(bool async) Assert.Equal(e.ConvexHull, a.ConvexHull, GeometryComparer.Instance); }); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task ConvexHull_aggregate(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(e => e.Point != null) + .GroupBy(e => e.Group) + .Select(g => new { Id = g.Key, ConvexHull = NetTopologySuite.Algorithm.ConvexHull.Create(g.Select(e => e.Point)) }), + elementSorter: x => x.Id, + elementAsserter: (e, a) => + { + Assert.Equal(e.Id, a.Id); + Assert.Equal(e.ConvexHull, a.ConvexHull, GeometryComparer.Instance); + }); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task IGeometryCollection_Count(bool async) @@ -1066,6 +1111,26 @@ public virtual Task Union(bool async) }); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Union_aggregate(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(e => e.Point != null) + .GroupBy(e => e.Group) + .Select(g => new + { + Id = g.Key, + Union = UnaryUnionOp.Union(g.Select(e => e.Point)) + }), + elementSorter: x => x.Id, + elementAsserter: (e, a) => + { + Assert.Equal(e.Id, a.Id); + Assert.Equal(e.Union, a.Union, GeometryComparer.Instance); + }); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Union_void(bool async) diff --git a/test/EFCore.Specification.Tests/TestModels/SpatialModel/PointEntity.cs b/test/EFCore.Specification.Tests/TestModels/SpatialModel/PointEntity.cs index 3acca73d652..af2e4b5067f 100644 --- a/test/EFCore.Specification.Tests/TestModels/SpatialModel/PointEntity.cs +++ b/test/EFCore.Specification.Tests/TestModels/SpatialModel/PointEntity.cs @@ -10,6 +10,7 @@ public class PointEntity public static readonly Guid WellKnownId = Guid.Parse("2F39AADE-4D8D-42D2-88CE-775C84AB83B1"); public Guid Id { get; set; } + public string Group { get; set; } public Geometry Geometry { get; set; } public Point Point { get; set; } public Point PointZ { get; set; } diff --git a/test/EFCore.Specification.Tests/TestModels/SpatialModel/SpatialData.cs b/test/EFCore.Specification.Tests/TestModels/SpatialModel/SpatialData.cs index cc11858c487..87c83b66d72 100644 --- a/test/EFCore.Specification.Tests/TestModels/SpatialModel/SpatialData.cs +++ b/test/EFCore.Specification.Tests/TestModels/SpatialModel/SpatialData.cs @@ -60,12 +60,27 @@ public static IReadOnlyList CreatePointEntities(GeometryFactory fac new PointEntity { Id = PointEntity.WellKnownId, + Group = "A", Point = factory.CreatePoint(new Coordinate(0, 0)), PointZ = factory.CreatePoint(new CoordinateZ(0, 0, 0)), PointM = factory.CreatePoint(new CoordinateM(0, 0, 0)), PointZM = factory.CreatePoint(new CoordinateZM(0, 0, 0, 0)) }, - new PointEntity { Id = Guid.Parse("67A54C9B-4C3B-4B27-8B4E-C0335E50E551"), Point = null } + new PointEntity + { + Id = Guid.Parse("2F39AADE-4D8D-42D2-88CE-775C84AB83B2"), + Group = "A", + Point = factory.CreatePoint(new Coordinate(1, 1)), + PointZ = factory.CreatePoint(new CoordinateZ(1, 1, 1)), + PointM = factory.CreatePoint(new CoordinateM(1, 1, 1)), + PointZM = factory.CreatePoint(new CoordinateZM(1, 1, 1, 1)) + }, + new PointEntity + { + Id = Guid.Parse("67A54C9B-4C3B-4B27-8B4E-C0335E50E551"), + Group = "B", + Point = null + } }; foreach (var entity in entities) diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/SpatialQuerySqlServerGeographyTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/SpatialQuerySqlServerGeographyTest.cs index ab64d96f9de..df2209c1d21 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/SpatialQuerySqlServerGeographyTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/SpatialQuerySqlServerGeographyTest.cs @@ -27,7 +27,7 @@ public override async Task SimpleSelect(bool async) await base.SimpleSelect(async); AssertSql( - @"SELECT [p].[Id], [p].[Geometry], [p].[Point], [p].[PointM], [p].[PointZ], [p].[PointZM] + @"SELECT [p].[Id], [p].[Geometry], [p].[Group], [p].[Point], [p].[PointM], [p].[PointZ], [p].[PointZM] FROM [PointEntity] AS [p]", // @"SELECT [l].[Id], [l].[LineString] @@ -109,6 +109,17 @@ public override Task Buffer_quadrantSegments(bool async) public override Task Centroid(bool async) => Task.CompletedTask; + public override async Task Combine_aggregate(bool async) + { + await base.Combine_aggregate(async); + + AssertSql( + @"SELECT [p].[Group] AS [Id], geography::CollectionAggregate([p].[Point]) AS [Combined] +FROM [PointEntity] AS [p] +WHERE [p].[Point] IS NOT NULL +GROUP BY [p].[Group]"); + } + public override async Task Contains(bool async) { await base.Contains(async); @@ -129,6 +140,17 @@ public override async Task ConvexHull(bool async) FROM [PolygonEntity] AS [p]"); } + public override async Task ConvexHull_aggregate(bool async) + { + await base.ConvexHull_aggregate(async); + + AssertSql( + @"SELECT [p].[Group] AS [Id], geography::ConvexHullAggregate([p].[Point]) AS [ConvexHull] +FROM [PointEntity] AS [p] +WHERE [p].[Point] IS NOT NULL +GROUP BY [p].[Group]"); + } + public override async Task IGeometryCollection_Count(bool async) { await base.IGeometryCollection_Count(async); @@ -637,6 +659,17 @@ public override async Task Union(bool async) FROM [PolygonEntity] AS [p]"); } + public override async Task Union_aggregate(bool async) + { + await base.Union_aggregate(async); + + AssertSql( + @"SELECT [p].[Group] AS [Id], geography::UnionAggregate([p].[Point]) AS [Union] +FROM [PointEntity] AS [p] +WHERE [p].[Point] IS NOT NULL +GROUP BY [p].[Group]"); + } + // No SqlServer Translation public override Task Union_void(bool async) => Task.CompletedTask; @@ -684,7 +717,7 @@ public override async Task XY_with_collection_join(bool async) await base.XY_with_collection_join(async); AssertSql( - @"SELECT [t].[Id], [t].[c], [t].[c0], [p0].[Id], [p0].[Geometry], [p0].[Point], [p0].[PointM], [p0].[PointZ], [p0].[PointZM] + @"SELECT [t].[Id], [t].[c], [t].[c0], [p0].[Id], [p0].[Geometry], [p0].[Group], [p0].[Point], [p0].[PointM], [p0].[PointZ], [p0].[PointZM] FROM ( SELECT TOP(1) [p].[Id], [p].[Point].Long AS [c], [p].[Point].Lat AS [c0] FROM [PointEntity] AS [p] diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/SpatialQuerySqlServerGeometryTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/SpatialQuerySqlServerGeometryTest.cs index 65b12767a2a..d50d4b39fbb 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/SpatialQuerySqlServerGeometryTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/SpatialQuerySqlServerGeometryTest.cs @@ -23,7 +23,7 @@ public override async Task SimpleSelect(bool async) await base.SimpleSelect(async); AssertSql( - @"SELECT [p].[Id], [p].[Geometry], [p].[Point], [p].[PointM], [p].[PointZ], [p].[PointZM] + @"SELECT [p].[Id], [p].[Geometry], [p].[Group], [p].[Point], [p].[PointM], [p].[PointZ], [p].[PointZM] FROM [PointEntity] AS [p]", // @"SELECT [l].[Id], [l].[LineString] @@ -115,6 +115,17 @@ public override async Task Centroid(bool async) FROM [PolygonEntity] AS [p]"); } + public override async Task Combine_aggregate(bool async) + { + await base.Combine_aggregate(async); + + AssertSql( + @"SELECT [p].[Group] AS [Id], geometry::CollectionAggregate([p].[Point]) AS [Combined] +FROM [PointEntity] AS [p] +WHERE [p].[Point] IS NOT NULL +GROUP BY [p].[Group]"); + } + public override async Task Contains(bool async) { await base.Contains(async); @@ -135,6 +146,17 @@ public override async Task ConvexHull(bool async) FROM [PolygonEntity] AS [p]"); } + public override async Task ConvexHull_aggregate(bool async) + { + await base.ConvexHull_aggregate(async); + + AssertSql( + @"SELECT [p].[Group] AS [Id], geometry::ConvexHullAggregate([p].[Point]) AS [ConvexHull] +FROM [PointEntity] AS [p] +WHERE [p].[Point] IS NOT NULL +GROUP BY [p].[Group]"); + } + public override async Task IGeometryCollection_Count(bool async) { await base.IGeometryCollection_Count(async); @@ -719,6 +741,17 @@ public override async Task Union(bool async) FROM [PolygonEntity] AS [p]"); } + public override async Task Union_aggregate(bool async) + { + await base.Union_aggregate(async); + + AssertSql( + @"SELECT [p].[Group] AS [Id], geometry::UnionAggregate([p].[Point]) AS [Union] +FROM [PointEntity] AS [p] +WHERE [p].[Point] IS NOT NULL +GROUP BY [p].[Group]"); + } + // No SqlServer Translation public override Task Union_void(bool async) => Task.CompletedTask; @@ -766,7 +799,7 @@ public override async Task XY_with_collection_join(bool async) await base.XY_with_collection_join(async); AssertSql( - @"SELECT [t].[Id], [t].[c], [t].[c0], [p0].[Id], [p0].[Geometry], [p0].[Point], [p0].[PointM], [p0].[PointZ], [p0].[PointZM] + @"SELECT [t].[Id], [t].[c], [t].[c0], [p0].[Id], [p0].[Geometry], [p0].[Group], [p0].[Point], [p0].[PointM], [p0].[PointZ], [p0].[PointZM] FROM ( SELECT TOP(1) [p].[Id], [p].[Point].STX AS [c], [p].[Point].STY AS [c0] FROM [PointEntity] AS [p] diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/SpatialQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/SpatialQuerySqliteTest.cs index 31bdaa4ab45..98a8031b51a 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/SpatialQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/SpatialQuerySqliteTest.cs @@ -18,7 +18,7 @@ public override async Task SimpleSelect(bool async) await base.SimpleSelect(async); AssertSql( - @"SELECT ""p"".""Id"", ""p"".""Geometry"", ""p"".""Point"", ""p"".""PointM"", ""p"".""PointZ"", ""p"".""PointZM"" + @"SELECT ""p"".""Id"", ""p"".""Geometry"", ""p"".""Group"", ""p"".""Point"", ""p"".""PointM"", ""p"".""PointZ"", ""p"".""PointZM"" FROM ""PointEntity"" AS ""p""", // @"SELECT ""l"".""Id"", ""l"".""LineString"" @@ -155,6 +155,17 @@ public override async Task Centroid(bool async) FROM ""PolygonEntity"" AS ""p"""); } + public override async Task Combine_aggregate(bool async) + { + await base.Combine_aggregate(async); + + AssertSql( + @"SELECT ""p"".""Group"" AS ""Id"", Collect(""p"".""Point"") AS ""Combined"" +FROM ""PointEntity"" AS ""p"" +WHERE ""p"".""Point"" IS NOT NULL +GROUP BY ""p"".""Group"""); + } + public override async Task Contains(bool async) { await base.Contains(async); @@ -177,6 +188,17 @@ public override async Task ConvexHull(bool async) FROM ""PolygonEntity"" AS ""p"""); } + public override async Task ConvexHull_aggregate(bool async) + { + await base.ConvexHull_aggregate(async); + + AssertSql( + @"SELECT ""p"".""Group"" AS ""Id"", ConvexHull(Collect(""p"".""Point"")) AS ""ConvexHull"" +FROM ""PointEntity"" AS ""p"" +WHERE ""p"".""Point"" IS NOT NULL +GROUP BY ""p"".""Group"""); + } + public override async Task IGeometryCollection_Count(bool async) { await base.IGeometryCollection_Count(async); @@ -749,6 +771,17 @@ public override async Task Union(bool async) FROM ""PolygonEntity"" AS ""p"""); } + public override async Task Union_aggregate(bool async) + { + await base.Union_aggregate(async); + + AssertSql( + @"SELECT ""p"".""Group"" AS ""Id"", GUnion(""p"".""Point"") AS ""Union"" +FROM ""PointEntity"" AS ""p"" +WHERE ""p"".""Point"" IS NOT NULL +GROUP BY ""p"".""Group"""); + } + public override async Task Union_void(bool async) { await base.Union_void(async);