From f2aeb6767235c0a9aad2d1e3eade3aba69a6963d Mon Sep 17 00:00:00 2001 From: nikitasavinov <6826684+nikitasavinov@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:15:08 +0200 Subject: [PATCH 1/2] feat: add SQL Server auto-grid spatial indexes --- .github/workflows/dotnetcore.yml | 4 +- .github/workflows/publish.yml | 2 +- Directory.Packages.props | 1 + ...ityFrameworkCore.Extensions.Samples.csproj | 1 + ...260820170557_AddSpatialIndexes.Designer.cs | 155 ++++++ .../20260820170557_AddSpatialIndexes.cs | 70 +++ .../Migrations/SampleContextModelSnapshot.cs | 48 ++ .../Models.cs | 14 + .../Program.cs | 19 +- .../packages.lock.json | 28 + ...ntityFrameworkCore.Extensions.Tests.csproj | 5 +- .../DynamicDataMaskingApiTests.cs | 59 ++ .../DynamicDataMaskingModelTests.cs | 114 ++++ .../DynamicDataMaskingSqlGeneratorTests.cs} | 134 +---- ...icDataMaskingSqlServerIntegrationTests.cs} | 86 +-- .../SpatialIndexes/SpatialIndexApiTests.cs | 158 ++++++ .../SpatialIndexes/SpatialIndexModelTests.cs | 502 ++++++++++++++++++ .../SpatialIndexSqlGeneratorTests.cs | 298 +++++++++++ .../SpatialIndexSqlServerIntegrationTests.cs | 288 ++++++++++ .../{ => Shared}/CustomSql/TestSql.sql | 0 .../DatabaseFacadeExtensionsTests.cs | 0 .../DbContextOptionsBuilderExtensionsTest.cs | 0 .../MigrationBuilderExtensionsTests.cs | 0 .../ModelBuilderExtensionsTests.cs | 0 .../SqlServerIntegrationTestDatabase.cs | 109 ++++ .../{ => Shared}/TestContext.cs | 0 .../packages.lock.json | 28 + .../EntityFrameworkCore.Extensions.csproj | 4 +- ...AnnotationConstants.DynamicDataMasking.cs} | 5 +- .../DynamicDataMaskingAnnotation.cs | 0 ...rAnnotationProvider.DynamicDataMasking.cs} | 12 +- ...rationsSqlGenerator.DynamicDataMasking.cs} | 32 +- .../DynamicDataMasking/MaskingFunctions.cs | 0 .../PropertyBuilderExtensions.cs | 0 .../AnnotationConstants.SpatialIndexes.cs | 39 ++ .../EntityTypeBuilderExtensions.cs | 227 ++++++++ ...ServerAnnotationProvider.SpatialIndexes.cs | 159 ++++++ ...erMigrationsSqlGenerator.SpatialIndexes.cs | 141 +++++ .../SpatialIndexes/SpatialIndexAnnotation.cs | 165 ++++++ .../SpatialIndexOptionsBuilder.cs | 85 +++ .../Shared/AnnotationConstants.cs | 8 + .../{ => Shared}/DatabaseFacadeExtensions.cs | 0 .../DbContextOptionsBuilderExtensions.cs | 0 .../MigrationBuilderExtensions.cs | 0 .../{ => Shared}/ModelBuilderExtensions.cs | 0 ...FrameworkCoreExtensionsOptionsExtension.cs | 0 .../ExtendedSqlServerAnnotationProvider.cs | 21 + ...ExtendedSqlServerMigrationsSqlGenerator.cs | 20 + README.md | 39 +- 49 files changed, 2829 insertions(+), 251 deletions(-) create mode 100644 EntityFrameworkCore.Extensions.Samples/Migrations/20260820170557_AddSpatialIndexes.Designer.cs create mode 100644 EntityFrameworkCore.Extensions.Samples/Migrations/20260820170557_AddSpatialIndexes.cs create mode 100644 EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingApiTests.cs create mode 100644 EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingModelTests.cs rename EntityFrameworkCore.Extensions.Tests/{DynamicDataMaskingTests.cs => Features/DynamicDataMasking/DynamicDataMaskingSqlGeneratorTests.cs} (79%) rename EntityFrameworkCore.Extensions.Tests/{SqlServerIntegrationTests.cs => Features/DynamicDataMasking/DynamicDataMaskingSqlServerIntegrationTests.cs} (84%) create mode 100644 EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexApiTests.cs create mode 100644 EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexModelTests.cs create mode 100644 EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexSqlGeneratorTests.cs create mode 100644 EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexSqlServerIntegrationTests.cs rename EntityFrameworkCore.Extensions.Tests/{ => Shared}/CustomSql/TestSql.sql (100%) rename EntityFrameworkCore.Extensions.Tests/{ => Shared}/DatabaseFacadeExtensionsTests.cs (100%) rename EntityFrameworkCore.Extensions.Tests/{ => Shared}/DbContextOptionsBuilderExtensionsTest.cs (100%) rename EntityFrameworkCore.Extensions.Tests/{ => Shared}/MigrationBuilderExtensionsTests.cs (100%) rename EntityFrameworkCore.Extensions.Tests/{ => Shared}/ModelBuilderExtensionsTests.cs (100%) create mode 100644 EntityFrameworkCore.Extensions.Tests/Shared/SqlServerIntegrationTestDatabase.cs rename EntityFrameworkCore.Extensions.Tests/{ => Shared}/TestContext.cs (100%) rename EntityFrameworkCore.Extensions/{AnnotationConstants.cs => Features/DynamicDataMasking/AnnotationConstants.DynamicDataMasking.cs} (60%) rename EntityFrameworkCore.Extensions/{Services => Features/DynamicDataMasking}/DynamicDataMaskingAnnotation.cs (100%) rename EntityFrameworkCore.Extensions/{Services/ExtendedSqlServerAnnotationProvider.cs => Features/DynamicDataMasking/ExtendedSqlServerAnnotationProvider.DynamicDataMasking.cs} (75%) rename EntityFrameworkCore.Extensions/{Services/ExtendedSqlServerMigrationsSqlGenerator.cs => Features/DynamicDataMasking/ExtendedSqlServerMigrationsSqlGenerator.DynamicDataMasking.cs} (87%) rename EntityFrameworkCore.Extensions/{ => Features}/DynamicDataMasking/MaskingFunctions.cs (100%) rename EntityFrameworkCore.Extensions/{ => Features/DynamicDataMasking}/PropertyBuilderExtensions.cs (100%) create mode 100644 EntityFrameworkCore.Extensions/Features/SpatialIndexes/AnnotationConstants.SpatialIndexes.cs create mode 100644 EntityFrameworkCore.Extensions/Features/SpatialIndexes/EntityTypeBuilderExtensions.cs create mode 100644 EntityFrameworkCore.Extensions/Features/SpatialIndexes/ExtendedSqlServerAnnotationProvider.SpatialIndexes.cs create mode 100644 EntityFrameworkCore.Extensions/Features/SpatialIndexes/ExtendedSqlServerMigrationsSqlGenerator.SpatialIndexes.cs create mode 100644 EntityFrameworkCore.Extensions/Features/SpatialIndexes/SpatialIndexAnnotation.cs create mode 100644 EntityFrameworkCore.Extensions/Features/SpatialIndexes/SpatialIndexOptionsBuilder.cs create mode 100644 EntityFrameworkCore.Extensions/Shared/AnnotationConstants.cs rename EntityFrameworkCore.Extensions/{ => Shared}/DatabaseFacadeExtensions.cs (100%) rename EntityFrameworkCore.Extensions/{ => Shared}/DbContextOptionsBuilderExtensions.cs (100%) rename EntityFrameworkCore.Extensions/{ => Shared}/MigrationBuilderExtensions.cs (100%) rename EntityFrameworkCore.Extensions/{ => Shared}/ModelBuilderExtensions.cs (100%) rename EntityFrameworkCore.Extensions/{ => Shared}/Services/EntityFrameworkCoreExtensionsOptionsExtension.cs (100%) create mode 100644 EntityFrameworkCore.Extensions/Shared/Services/ExtendedSqlServerAnnotationProvider.cs create mode 100644 EntityFrameworkCore.Extensions/Shared/Services/ExtendedSqlServerMigrationsSqlGenerator.cs diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index ae2d455..04892b2 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -46,7 +46,7 @@ jobs: - name: Test run: >- dotnet test --solution EntityFrameworkCore.Extensions.sln - --configuration Release --no-build --minimum-expected-tests 35 + --configuration Release --no-build sqlserver-integration: name: SQL Server integration @@ -86,7 +86,7 @@ jobs: - name: Test against SQL Server run: >- dotnet test --solution EntityFrameworkCore.Extensions.sln - --configuration Release --no-build --minimum-expected-tests 35 --fail-skips on + --configuration Release --no-build --fail-skips on package: name: Validate package diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c9b6259..0b3a0b5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -81,7 +81,7 @@ jobs: - name: Test against SQL Server run: >- dotnet test --solution EntityFrameworkCore.Extensions.sln - --configuration Release --no-build --minimum-expected-tests 35 --fail-skips on + --configuration Release --no-build --fail-skips on - name: Pack run: >- dotnet pack EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index c9d2b72..94279c6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/EntityFrameworkCore.Extensions.Samples/EntityFrameworkCore.Extensions.Samples.csproj b/EntityFrameworkCore.Extensions.Samples/EntityFrameworkCore.Extensions.Samples.csproj index 93036e2..de9fb2c 100644 --- a/EntityFrameworkCore.Extensions.Samples/EntityFrameworkCore.Extensions.Samples.csproj +++ b/EntityFrameworkCore.Extensions.Samples/EntityFrameworkCore.Extensions.Samples.csproj @@ -20,5 +20,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/EntityFrameworkCore.Extensions.Samples/Migrations/20260820170557_AddSpatialIndexes.Designer.cs b/EntityFrameworkCore.Extensions.Samples/Migrations/20260820170557_AddSpatialIndexes.Designer.cs new file mode 100644 index 0000000..7374a77 --- /dev/null +++ b/EntityFrameworkCore.Extensions.Samples/Migrations/20260820170557_AddSpatialIndexes.Designer.cs @@ -0,0 +1,155 @@ +// +using System; +using EntityFrameworkCore.Extensions.Samples; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; + +#nullable disable + +namespace EntityFrameworkCore.Extensions.Samples.Migrations +{ + [DbContext(typeof(Program.SampleContext))] + [Migration("20260820170557_AddSpatialIndexes")] + partial class AddSpatialIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DiscountCardNumber") + .HasColumnType("int") + .HasAnnotation("DynamicDataMasking", "random(10, 100)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasAnnotation("DynamicDataMasking", "partial(2, \"XX-XX\", 1)"); + + b.Property("SampleProperty1") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SampleProperty2") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Surname") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasAnnotation("DynamicDataMasking", "default()"); + + b.Property("Surname2") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Order"); + }); + + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Location") + .IsRequired() + .HasColumnType("geography"); + + b.HasKey("Id"); + + b.HasIndex("Location") + .HasDatabaseName("SIX_Places_Location") + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndex", true); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Boundary") + .IsRequired() + .HasColumnType("geometry"); + + b.HasKey("Id"); + + b.HasIndex("Boundary") + .HasDatabaseName("SIX_Regions_Boundary") + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndex", true) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxXMax", 180.0) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxXMin", -180.0) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxYMax", 90.0) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxYMin", -90.0) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexCellsPerObject", 32); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Order", b => + { + b.HasOne("EntityFrameworkCore.Extensions.Samples.Customer", null) + .WithMany("Orders") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Customer", b => + { + b.Navigation("Orders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EntityFrameworkCore.Extensions.Samples/Migrations/20260820170557_AddSpatialIndexes.cs b/EntityFrameworkCore.Extensions.Samples/Migrations/20260820170557_AddSpatialIndexes.cs new file mode 100644 index 0000000..405e9e4 --- /dev/null +++ b/EntityFrameworkCore.Extensions.Samples/Migrations/20260820170557_AddSpatialIndexes.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NetTopologySuite.Geometries; + +#nullable disable + +namespace EntityFrameworkCore.Extensions.Samples.Migrations +{ + /// + public partial class AddSpatialIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Places", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Location = table.Column(type: "geography", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Places", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Regions", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Boundary = table.Column(type: "geometry", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Regions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "SIX_Places_Location", + table: "Places", + column: "Location") + .Annotation("EntityFrameworkCore.Extensions:SpatialIndex", true) + .Annotation("EntityFrameworkCore.Extensions:SpatialIndexType", "geography"); + + migrationBuilder.CreateIndex( + name: "SIX_Regions_Boundary", + table: "Regions", + column: "Boundary") + .Annotation("EntityFrameworkCore.Extensions:SpatialIndex", true) + .Annotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxXMax", 180.0) + .Annotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxXMin", -180.0) + .Annotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxYMax", 90.0) + .Annotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxYMin", -90.0) + .Annotation("EntityFrameworkCore.Extensions:SpatialIndexCellsPerObject", 32) + .Annotation("EntityFrameworkCore.Extensions:SpatialIndexType", "geometry"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Places"); + + migrationBuilder.DropTable( + name: "Regions"); + } + } +} diff --git a/EntityFrameworkCore.Extensions.Samples/Migrations/SampleContextModelSnapshot.cs b/EntityFrameworkCore.Extensions.Samples/Migrations/SampleContextModelSnapshot.cs index eb84e34..fe715a9 100644 --- a/EntityFrameworkCore.Extensions.Samples/Migrations/SampleContextModelSnapshot.cs +++ b/EntityFrameworkCore.Extensions.Samples/Migrations/SampleContextModelSnapshot.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; #nullable disable @@ -86,6 +87,53 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Order"); }); + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Location") + .IsRequired() + .HasColumnType("geography"); + + b.HasKey("Id"); + + b.HasIndex("Location") + .HasDatabaseName("SIX_Places_Location") + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndex", true); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Boundary") + .IsRequired() + .HasColumnType("geometry"); + + b.HasKey("Id"); + + b.HasIndex("Boundary") + .HasDatabaseName("SIX_Regions_Boundary") + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndex", true) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxXMax", 180.0) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxXMin", -180.0) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxYMax", 90.0) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxYMin", -90.0) + .HasAnnotation("EntityFrameworkCore.Extensions:SpatialIndexCellsPerObject", 32); + + b.ToTable("Regions"); + }); + modelBuilder.Entity("EntityFrameworkCore.Extensions.Samples.Order", b => { b.HasOne("EntityFrameworkCore.Extensions.Samples.Customer", null) diff --git a/EntityFrameworkCore.Extensions.Samples/Models.cs b/EntityFrameworkCore.Extensions.Samples/Models.cs index 8bb325a..540f4b6 100644 --- a/EntityFrameworkCore.Extensions.Samples/Models.cs +++ b/EntityFrameworkCore.Extensions.Samples/Models.cs @@ -1,3 +1,5 @@ +using NetTopologySuite.Geometries; + namespace EntityFrameworkCore.Extensions.Samples; public class Customer @@ -19,3 +21,15 @@ public class Order public int Id { get; set; } public DateTime Created { get; set; } } + +public class Place +{ + public int Id { get; set; } + public Point Location { get; set; } = new(0, 0) { SRID = 4326 }; +} + +public class Region +{ + public int Id { get; set; } + public Polygon Boundary { get; set; } = null!; +} diff --git a/EntityFrameworkCore.Extensions.Samples/Program.cs b/EntityFrameworkCore.Extensions.Samples/Program.cs index ec87575..fd350b6 100644 --- a/EntityFrameworkCore.Extensions.Samples/Program.cs +++ b/EntityFrameworkCore.Extensions.Samples/Program.cs @@ -7,13 +7,16 @@ internal sealed class Program public sealed class SampleContext : DbContext { public DbSet Customers => Set(); + public DbSet Places => Set(); + public DbSet Regions => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) { optionsBuilder.UseSqlServer( - "Data Source=.;Initial Catalog=EntityFrameworkCoreExtensionsSamples;Integrated Security=True;TrustServerCertificate=True"); + "Data Source=.;Initial Catalog=EntityFrameworkCoreExtensionsSamples;Integrated Security=True;TrustServerCertificate=True", + sqlServer => sqlServer.UseNetTopologySuite()); } optionsBuilder.UseEntityFrameworkCoreExtensions(); @@ -26,6 +29,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().Property(customer => customer.Surname).HasDataMask(MaskingFunctions.Default()); modelBuilder.Entity().Property(customer => customer.DiscountCardNumber).HasDataMask(MaskingFunctions.Random(10, 100)); modelBuilder.Entity().Property(customer => customer.Phone).HasDataMask(MaskingFunctions.Partial(2, "XX-XX", 1)); + + modelBuilder.Entity().Property(place => place.Location).HasColumnType("geography"); + modelBuilder.Entity() + .HasSpatialIndex(place => place.Location) + .HasDatabaseName("SIX_Places_Location"); + + modelBuilder.Entity().Property(region => region.Boundary).HasColumnType("geometry"); + modelBuilder.Entity() + .HasSpatialIndex( + region => region.Boundary, + spatial => spatial + .HasBoundingBox(-180, -90, 180, 90) + .HasCellsPerObject(32)) + .HasDatabaseName("SIX_Regions_Boundary"); } } diff --git a/EntityFrameworkCore.Extensions.Samples/packages.lock.json b/EntityFrameworkCore.Extensions.Samples/packages.lock.json index 475f4a8..2593ee3 100644 --- a/EntityFrameworkCore.Extensions.Samples/packages.lock.json +++ b/EntityFrameworkCore.Extensions.Samples/packages.lock.json @@ -35,6 +35,21 @@ "Microsoft.Extensions.Logging": "10.0.11" } }, + "Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite": { + "type": "Direct", + "requested": "[10.0.11, 11.0.0)", + "resolved": "10.0.11", + "contentHash": "wG2z6o6vDnLmT3cgEyu1MyGEQkg+Mff6E9Y8MIV0h4oSSamj/PeEUZLkgaw/qJsfNJvCBXAmWu4Ywsk98CDwuA==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.6", + "Microsoft.EntityFrameworkCore.SqlServer": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "NetTopologySuite": "2.6.0", + "NetTopologySuite.IO.SqlServerBytes": "2.1.0" + } + }, "Azure.Core": { "type": "Transitive", "resolved": "1.50.0", @@ -364,6 +379,19 @@ "System.CodeDom": "6.0.0" } }, + "NetTopologySuite": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "1B1OTacTd4QtFyBeuIOcThwSSLUdRZU3bSFIwM8vk36XiZlBMi3K36u74e4OqwwHRHUuJC1PhbDx4hyI266X1Q==" + }, + "NetTopologySuite.IO.SqlServerBytes": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "R4BcV19f2l6EjHSjh/EHwLhYQHrOov9vig1EW5oBm0iqlZOgaIJm5tBnlbFnYlvdYOPuf5p0Qtf8PCVwH77Wbg==", + "dependencies": { + "NetTopologySuite": "[2.0.0, 3.0.0-A)" + } + }, "Newtonsoft.Json": { "type": "Transitive", "resolved": "13.0.4", diff --git a/EntityFrameworkCore.Extensions.Tests/EntityFrameworkCore.Extensions.Tests.csproj b/EntityFrameworkCore.Extensions.Tests/EntityFrameworkCore.Extensions.Tests.csproj index 70ec754..f6f7ae1 100644 --- a/EntityFrameworkCore.Extensions.Tests/EntityFrameworkCore.Extensions.Tests.csproj +++ b/EntityFrameworkCore.Extensions.Tests/EntityFrameworkCore.Extensions.Tests.csproj @@ -7,12 +7,15 @@ - + + diff --git a/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingApiTests.cs b/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingApiTests.cs new file mode 100644 index 0000000..a817d11 --- /dev/null +++ b/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingApiTests.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EntityFrameworkCore.Extensions.Tests; + +public sealed class DynamicDataMaskingApiTests +{ + [Fact] + public void DefaultMaskingFunctionGeneratesExpectedExpression() + => Assert.Equal("default()", MaskingFunctions.Default()); + + [Fact] + public void EmailMaskingFunctionGeneratesExpectedExpression() + => Assert.Equal("email()", MaskingFunctions.Email()); + + [Fact] + public void ParameterizedMaskingFunctionsGenerateExpectedExpressions() + { + Assert.Equal("random(10, 100)", MaskingFunctions.Random(10, 100)); + Assert.Equal("partial(2, \"XX-XX\", 1)", MaskingFunctions.Partial(2, "XX-XX", 1)); + } + + [Fact] + public void PartialMaskingFunctionRejectsDoubleQuoteInPadding() + { + var exception = Assert.Throws(() => MaskingFunctions.Partial(1, "a\"b", 1)); + + Assert.Equal("padding", exception.ParamName); + } + + [Fact] + public void HasDataMaskStoresAnnotationAndReturnsPropertyBuilder() + { + var modelBuilder = new ModelBuilder(); + var propertyBuilder = modelBuilder.Entity().Property(entity => entity.Secret); + + var result = propertyBuilder.HasDataMask(MaskingFunctions.Email()); + + Assert.Same(propertyBuilder, result); + Assert.Equal( + MaskingFunctions.Email(), + propertyBuilder.Metadata.FindAnnotation(AnnotationConstants.DynamicDataMasking)?.Value); + } + + [Fact] + public void HasDataMaskRejectsEmptyPattern() + { + var modelBuilder = new ModelBuilder(); + var propertyBuilder = modelBuilder.Entity().Property(entity => entity.Secret); + + Assert.Throws(() => propertyBuilder.HasDataMask(" ")); + } + + private sealed class SecretEntity + { + public int Id { get; set; } + public string Secret { get; set; } = string.Empty; + } +} diff --git a/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingModelTests.cs b/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingModelTests.cs new file mode 100644 index 0000000..937a5ae --- /dev/null +++ b/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingModelTests.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Xunit; + +namespace EntityFrameworkCore.Extensions.Tests; + +public sealed class DynamicDataMaskingModelTests +{ + private const string ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=NotUsed"; + + [Fact] + public void RuntimeModelDoesNotContainMigrationMaskingAnnotation() + { + using var context = CreateMaskedContext(); + + var runtimeColumn = context.Model.GetRelationalModel() + .FindTable("Order", "odd]schema")! + .FindColumn("Select]")!; + var designColumn = context.GetService().Model.GetRelationalModel() + .FindTable("Order", "odd]schema")! + .FindColumn("Select]")!; + + Assert.Null(runtimeColumn.FindAnnotation(AnnotationConstants.DynamicDataMasking)); + Assert.Equal( + MaskingFunctions.Default(), + designColumn.FindAnnotation(AnnotationConstants.DynamicDataMasking)?.Value); + } + + [Fact] + public void ConflictingMasksOnSharedColumnAreRejected() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString) + .UseEntityFrameworkCoreExtensions() + .Options; + using var context = new ConflictingSharedColumnContext(options); + + var exception = Assert.Throws( + () => _ = context.GetService().Model.GetRelationalModel()); + + Assert.Contains("conflicting dynamic data masks", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SharedSecrets.Secret", exception.Message, StringComparison.Ordinal); + } + + private static MaskedContext CreateMaskedContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString) + .UseEntityFrameworkCoreExtensions() + .Options; + return new MaskedContext(options); + } + + private sealed class MaskedContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("Order", "odd]schema"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Secret) + .HasColumnName("Select]") + .HasDataMask(MaskingFunctions.Default()); + } + } + + private sealed class ConflictingSharedColumnContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entityBuilder => + { + entityBuilder.ToTable("SharedSecrets"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Secret) + .HasColumnName("Secret") + .HasDataMask(MaskingFunctions.Default()); + entityBuilder.HasOne(entity => entity.Details) + .WithOne() + .HasForeignKey(entity => entity.Id); + }); + + modelBuilder.Entity(entityBuilder => + { + entityBuilder.ToTable("SharedSecrets"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Secret) + .HasColumnName("Secret") + .HasDataMask(MaskingFunctions.Email()); + }); + } + } + + private sealed class SecretEntity + { + public int Id { get; set; } + public string Secret { get; set; } = string.Empty; + } + + private sealed class SharedColumnPrincipal + { + public int Id { get; set; } + public string Secret { get; set; } = string.Empty; + public SharedColumnDetails Details { get; set; } = null!; + } + + private sealed class SharedColumnDetails + { + public int Id { get; set; } + public string Secret { get; set; } = string.Empty; + } +} diff --git a/EntityFrameworkCore.Extensions.Tests/DynamicDataMaskingTests.cs b/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingSqlGeneratorTests.cs similarity index 79% rename from EntityFrameworkCore.Extensions.Tests/DynamicDataMaskingTests.cs rename to EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingSqlGeneratorTests.cs index c3a3cb9..e9ea1bc 100644 --- a/EntityFrameworkCore.Extensions.Tests/DynamicDataMaskingTests.cs +++ b/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingSqlGeneratorTests.cs @@ -10,47 +10,10 @@ namespace EntityFrameworkCore.Extensions.Tests; -public sealed class DynamicDataMaskingTests +public sealed class DynamicDataMaskingSqlGeneratorTests { private const string ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=NotUsed"; - [Fact] - public void DefaultMaskingFunctionGeneratesExpectedExpression() - => Assert.Equal("default()", MaskingFunctions.Default()); - - [Fact] - public void EmailMaskingFunctionGeneratesExpectedExpression() - => Assert.Equal("email()", MaskingFunctions.Email()); - - [Fact] - public void ParameterizedMaskingFunctionsGenerateExpectedExpressions() - { - Assert.Equal("random(10, 100)", MaskingFunctions.Random(10, 100)); - Assert.Equal("partial(2, \"XX-XX\", 1)", MaskingFunctions.Partial(2, "XX-XX", 1)); - } - - [Fact] - public void PartialMaskingFunctionRejectsDoubleQuoteInPadding() - { - var exception = Assert.Throws(() => MaskingFunctions.Partial(1, "a\"b", 1)); - - Assert.Equal("padding", exception.ParamName); - } - - [Fact] - public void HasDataMaskStoresAnnotationAndReturnsPropertyBuilder() - { - var modelBuilder = new ModelBuilder(); - var propertyBuilder = modelBuilder.Entity().Property(entity => entity.Secret); - - var result = propertyBuilder.HasDataMask(MaskingFunctions.Email()); - - Assert.Same(propertyBuilder, result); - Assert.Equal( - MaskingFunctions.Email(), - propertyBuilder.Metadata.FindAnnotation(AnnotationConstants.DynamicDataMasking)?.Value); - } - [Fact] public void CreatingMaskedModelPropagatesAnnotationAndGeneratesMaskSql() { @@ -99,7 +62,7 @@ public void AddingMaskGeneratesAddMaskedSql() } [Fact] - public void RemovingMaskGeneratesOneCatalogGuardedDrop() + public void RemovingMaskGeneratesOneCatalogGuardedDynamicDrop() { using var sourceContext = CreateMaskedContext(); using var targetContext = CreateUnmaskedContext(); @@ -120,9 +83,10 @@ public void RemovingMaskGeneratesOneCatalogGuardedDrop() Assert.Contains("OBJECT_ID(N'[odd]]schema].[Order]')", command.CommandText, StringComparison.Ordinal); Assert.Contains("[name] = N'Select]'", command.CommandText, StringComparison.Ordinal); Assert.Contains("[is_masked] = 1", command.CommandText, StringComparison.Ordinal); - Assert.EndsWith( - "ALTER TABLE [odd]]schema].[Order] ALTER COLUMN [Select]]] DROP MASKED;", - command.CommandText.Trim()); + Assert.Contains( + "EXEC(N'ALTER TABLE [odd]]schema].[Order] ALTER COLUMN [Select]]] DROP MASKED;');\nEND;", + command.CommandText.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); } [Fact] @@ -162,7 +126,7 @@ public void RemovingMaskDuringStructuralAlterUsesSafeGuardedDrop() Assert.Contains("FROM [sys].[masked_columns]", command.CommandText, StringComparison.Ordinal); Assert.Contains("[is_masked] = 1", command.CommandText, StringComparison.Ordinal); Assert.Contains( - "ALTER TABLE [odd]]schema].[Order] ALTER COLUMN [Select]]] DROP MASKED;", + "EXEC(N'ALTER TABLE [odd]]schema].[Order] ALTER COLUMN [Select]]] DROP MASKED;');", command.CommandText, StringComparison.Ordinal); }); @@ -353,49 +317,6 @@ public void InvalidMaskingAnnotationReportsAConfigurationError() Assert.Contains(AnnotationConstants.DynamicDataMasking, exception.Message, StringComparison.Ordinal); } - [Fact] - public void RuntimeModelDoesNotContainMigrationMaskingAnnotation() - { - using var context = CreateMaskedContext(); - - var runtimeColumn = context.Model.GetRelationalModel() - .FindTable("Order", "odd]schema")! - .FindColumn("Select]")!; - var designColumn = context.GetService().Model.GetRelationalModel() - .FindTable("Order", "odd]schema")! - .FindColumn("Select]")!; - - Assert.Null(runtimeColumn.FindAnnotation(AnnotationConstants.DynamicDataMasking)); - Assert.Equal( - MaskingFunctions.Default(), - designColumn.FindAnnotation(AnnotationConstants.DynamicDataMasking)?.Value); - } - - [Fact] - public void ConflictingMasksOnSharedColumnAreRejected() - { - var options = new DbContextOptionsBuilder() - .UseSqlServer(ConnectionString) - .UseEntityFrameworkCoreExtensions() - .Options; - using var context = new ConflictingSharedColumnContext(options); - - var exception = Assert.Throws( - () => _ = context.GetService().Model.GetRelationalModel()); - - Assert.Contains("conflicting dynamic data masks", exception.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("SharedSecrets.Secret", exception.Message, StringComparison.Ordinal); - } - - [Fact] - public void HasDataMaskRejectsEmptyPattern() - { - var modelBuilder = new ModelBuilder(); - var propertyBuilder = modelBuilder.Entity().Property(entity => entity.Secret); - - Assert.Throws(() => propertyBuilder.HasDataMask(" ")); - } - private static MaskedContext CreateMaskedContext() { var options = new DbContextOptionsBuilder() @@ -546,45 +467,4 @@ private sealed class SecretEntity public string Secret { get; set; } = string.Empty; } - private sealed class ConflictingSharedColumnContext(DbContextOptions options) - : DbContext(options) - { - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entityBuilder => - { - entityBuilder.ToTable("SharedSecrets"); - entityBuilder.HasKey(entity => entity.Id); - entityBuilder.Property(entity => entity.Secret) - .HasColumnName("Secret") - .HasDataMask(MaskingFunctions.Default()); - entityBuilder.HasOne(entity => entity.Details) - .WithOne() - .HasForeignKey(entity => entity.Id); - }); - - modelBuilder.Entity(entityBuilder => - { - entityBuilder.ToTable("SharedSecrets"); - entityBuilder.HasKey(entity => entity.Id); - entityBuilder.Property(entity => entity.Secret) - .HasColumnName("Secret") - .HasDataMask(MaskingFunctions.Email()); - }); - } - } - - private sealed class SharedColumnPrincipal - { - public int Id { get; set; } - public string Secret { get; set; } = string.Empty; - public SharedColumnDetails Details { get; set; } = null!; - } - - private sealed class SharedColumnDetails - { - public int Id { get; set; } - public string Secret { get; set; } = string.Empty; - } - } diff --git a/EntityFrameworkCore.Extensions.Tests/SqlServerIntegrationTests.cs b/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingSqlServerIntegrationTests.cs similarity index 84% rename from EntityFrameworkCore.Extensions.Tests/SqlServerIntegrationTests.cs rename to EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingSqlServerIntegrationTests.cs index 65b480e..1edeb37 100644 --- a/EntityFrameworkCore.Extensions.Tests/SqlServerIntegrationTests.cs +++ b/EntityFrameworkCore.Extensions.Tests/Features/DynamicDataMasking/DynamicDataMaskingSqlServerIntegrationTests.cs @@ -1,20 +1,17 @@ -using System.Data.Common; -using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Operations; +using static EntityFrameworkCore.Extensions.Tests.SqlServerIntegrationTestDatabase; using Xunit; namespace EntityFrameworkCore.Extensions.Tests; -public sealed class SqlServerIntegrationTests +public sealed class DynamicDataMaskingSqlServerIntegrationTests { - private const string ConnectionStringEnvironmentVariable = "EFCORE_EXTENSIONS_SQLSERVER"; - public static bool HasSqlServerConnectionString - => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable)); + => IsConfigured; [Fact( Skip = $"Set {ConnectionStringEnvironmentVariable} to run SQL Server integration tests.", @@ -208,43 +205,6 @@ await ExecuteMigrationOperationsAsync( } } - private static async Task CreateDatabaseConnectionStringAsync( - string databaseName, - CancellationToken cancellationToken) - { - var configuredConnectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable)!; - var masterConnectionString = new SqlConnectionStringBuilder(configuredConnectionString) - { - InitialCatalog = "master", - TrustServerCertificate = true - }.ConnectionString; - - await WaitForSqlServerAsync(masterConnectionString, cancellationToken); - - return new SqlConnectionStringBuilder(masterConnectionString) - { - InitialCatalog = databaseName - }.ConnectionString; - } - - private static async Task WaitForSqlServerAsync(string connectionString, CancellationToken cancellationToken) - { - const int maximumAttempts = 60; - for (var attempt = 1; attempt <= maximumAttempts; attempt++) - { - try - { - await using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken); - return; - } - catch (SqlException) when (attempt < maximumAttempts) - { - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } - } - } - private static MaskedDatabaseContext CreateMaskedContext(string connectionString) { var options = new DbContextOptionsBuilder() @@ -299,25 +259,6 @@ private static ResizedUnmaskedDatabaseContext CreateResizedUnmaskedContext(strin return new ResizedUnmaskedDatabaseContext(options); } - private static async Task ExecuteMigrationOperationsAsync( - DbContext context, - IReadOnlyList operations, - IModel model, - CancellationToken cancellationToken) - { - var connection = context.Database.GetDbConnection(); - if (connection.State != System.Data.ConnectionState.Open) - { - await connection.OpenAsync(cancellationToken); - } - - var commands = context.GetService().Generate(operations, model); - foreach (var migrationCommand in commands) - { - await ExecuteNonQueryAsync(connection, migrationCommand.CommandText, cancellationToken); - } - } - private static async Task GetMaskingFunctionAsync(DbContext context, CancellationToken cancellationToken) { var connection = context.Database.GetDbConnection(); @@ -341,26 +282,6 @@ FROM sys.masked_columns AS column_definition cancellationToken); } - private static async Task ExecuteScalarAsync( - DbConnection connection, - string commandText, - CancellationToken cancellationToken) - { - await using var command = connection.CreateCommand(); - command.CommandText = commandText; - return await command.ExecuteScalarAsync(cancellationToken); - } - - private static async Task ExecuteNonQueryAsync( - DbConnection connection, - string commandText, - CancellationToken cancellationToken) - { - await using var command = connection.CreateCommand(); - command.CommandText = commandText; - _ = await command.ExecuteNonQueryAsync(cancellationToken); - } - private static void ConfigureModel( ModelBuilder modelBuilder, string? maskingFunction, @@ -428,4 +349,5 @@ private sealed class MaskedCustomer public int Id { get; set; } public string Email { get; set; } = string.Empty; } + } diff --git a/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexApiTests.cs b/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexApiTests.cs new file mode 100644 index 0000000..fa4c766 --- /dev/null +++ b/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexApiTests.cs @@ -0,0 +1,158 @@ +using Microsoft.EntityFrameworkCore; +using EntityFrameworkCore.Extensions.Services; +using NetTopologySuite.Geometries; +using Xunit; + +namespace EntityFrameworkCore.Extensions.Tests; + +public sealed class SpatialIndexApiTests +{ + [Fact] + public void HasSpatialIndexStoresPrimitiveAnnotationsAndReturnsIndexBuilder() + { + var modelBuilder = new ModelBuilder(); + var entityBuilder = modelBuilder.Entity(); + + var indexBuilder = entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => options + .HasBoundingBox(-10.5, -20.25, 30.75, 40.5) + .HasCellsPerObject(32)); + + Assert.Same(indexBuilder.Metadata, entityBuilder.Metadata.GetIndexes().Single()); + Assert.Equal(true, indexBuilder.Metadata[AnnotationConstants.SpatialIndex]); + Assert.Equal(-10.5, indexBuilder.Metadata[AnnotationConstants.SpatialIndexBoundingBoxXMin]); + Assert.Equal(-20.25, indexBuilder.Metadata[AnnotationConstants.SpatialIndexBoundingBoxYMin]); + Assert.Equal(30.75, indexBuilder.Metadata[AnnotationConstants.SpatialIndexBoundingBoxXMax]); + Assert.Equal(40.5, indexBuilder.Metadata[AnnotationConstants.SpatialIndexBoundingBoxYMax]); + Assert.Equal(32, indexBuilder.Metadata[AnnotationConstants.SpatialIndexCellsPerObject]); + } + + [Fact] + public void StringHasSpatialIndexConfiguresNamedProperty() + { + var modelBuilder = new ModelBuilder(); + var entityBuilder = modelBuilder.Entity(); + + var indexBuilder = entityBuilder.HasSpatialIndex( + nameof(SpatialEntity.Location), + options => options.HasCellsPerObject(16)); + + Assert.Equal(nameof(SpatialEntity.Location), Assert.Single(indexBuilder.Metadata.Properties).Name); + Assert.Equal(true, indexBuilder.Metadata[AnnotationConstants.SpatialIndex]); + Assert.Equal(16, indexBuilder.Metadata[AnnotationConstants.SpatialIndexCellsPerObject]); + } + + [Fact] + public void NamedSpatialIndexesAllowMultipleIndexesOnOneProperty() + { + var modelBuilder = new ModelBuilder(); + var entityBuilder = modelBuilder.Entity(); + + var coarseIndex = entityBuilder.HasSpatialIndex( + entity => entity.Location, + "CoarseLocationSpatialIndex", + options => options.HasCellsPerObject(16)); + var fineIndex = entityBuilder.HasSpatialIndex( + nameof(SpatialEntity.Location), + "FineLocationSpatialIndex", + options => options.HasCellsPerObject(64)); + + Assert.Equal("CoarseLocationSpatialIndex", coarseIndex.Metadata.Name); + Assert.Equal(16, coarseIndex.Metadata[AnnotationConstants.SpatialIndexCellsPerObject]); + Assert.Equal("FineLocationSpatialIndex", fineIndex.Metadata.Name); + Assert.Equal(64, fineIndex.Metadata[AnnotationConstants.SpatialIndexCellsPerObject]); + Assert.Equal(2, entityBuilder.Metadata.GetIndexes().Count()); + } + + [Fact] + public void OwnedNavigationHasSpatialIndexSupportsExpressionAndStringOverloads() + { + var modelBuilder = new ModelBuilder(); + var ownedNavigationBuilder = modelBuilder.Entity() + .OwnsOne(owner => owner.Details); + + var expressionIndex = ownedNavigationBuilder.HasSpatialIndex( + details => details.Location, + "OwnedExpressionSpatialIndex", + options => options.HasCellsPerObject(16)); + var stringIndex = ownedNavigationBuilder.HasSpatialIndex( + nameof(OwnedSpatialEntity.Location), + "OwnedStringSpatialIndex", + options => options.HasCellsPerObject(64)); + + Assert.Equal("OwnedExpressionSpatialIndex", expressionIndex.Metadata.Name); + Assert.Equal(16, expressionIndex.Metadata[AnnotationConstants.SpatialIndexCellsPerObject]); + Assert.Equal("OwnedStringSpatialIndex", stringIndex.Metadata.Name); + Assert.Equal(64, stringIndex.Metadata[AnnotationConstants.SpatialIndexCellsPerObject]); + } + + [Fact] + public void SpatialIndexOptionsRejectInvalidValues() + { + var modelBuilder = new ModelBuilder(); + var entityBuilder = modelBuilder.Entity(); + + Assert.Throws(() => entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => options.HasBoundingBox(double.NaN, -10, 10, 10))); + Assert.Throws(() => entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => options.HasBoundingBox(10, -10, 10, 10))); + Assert.Throws(() => entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => options.HasBoundingBox(-10, 10, 10, 10))); + Assert.Throws(() => entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => options.HasCellsPerObject(0))); + Assert.Throws(() => entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => options.HasCellsPerObject(8193))); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void SpatialIndexAnnotationsRejectPartialBoundingBox(int coordinateCount) + { + var modelBuilder = new ModelBuilder(); + var indexBuilder = modelBuilder.Entity() + .HasIndex(entity => entity.Location) + .HasAnnotation(AnnotationConstants.SpatialIndex, true); + var coordinateNames = new[] + { + AnnotationConstants.SpatialIndexBoundingBoxXMin, + AnnotationConstants.SpatialIndexBoundingBoxYMin, + AnnotationConstants.SpatialIndexBoundingBoxXMax, + AnnotationConstants.SpatialIndexBoundingBoxYMax, + }; + + for (var index = 0; index < coordinateCount; index++) + { + indexBuilder.HasAnnotation(coordinateNames[index], (double)index); + } + + var exception = Assert.Throws( + () => SpatialIndexAnnotation.GetOptions(indexBuilder.Metadata, "Places.SIX_Location")); + + Assert.Contains("all four bounding-box coordinates", exception.Message, StringComparison.Ordinal); + } + + private sealed class SpatialEntity + { + public int Id { get; set; } + public Point Location { get; set; } = new(0, 0) { SRID = 4326 }; + } + + private sealed class SpatialOwner + { + public int Id { get; set; } + public OwnedSpatialEntity Details { get; set; } = new(); + } + + private sealed class OwnedSpatialEntity + { + public Point Location { get; set; } = new(0, 0) { SRID = 4326 }; + } +} diff --git a/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexModelTests.cs b/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexModelTests.cs new file mode 100644 index 0000000..afe33cb --- /dev/null +++ b/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexModelTests.cs @@ -0,0 +1,502 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using NetTopologySuite.Geometries; +using Xunit; + +namespace EntityFrameworkCore.Extensions.Tests; + +public sealed class SpatialIndexModelTests +{ + private const string ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=NotUsed"; + + [Fact] + public void RuntimeModelDoesNotContainSpatialMigrationAnnotations() + { + using var context = new GeographyContext(CreateOptions()); + + var runtimeIndex = context.Model.GetRelationalModel() + .FindTable("Places", "odd]schema")! + .Indexes.Single(); + var designIndex = GetDesignModel(context).GetRelationalModel() + .FindTable("Places", "odd]schema")! + .Indexes.Single(); + + Assert.Null(runtimeIndex.FindAnnotation(AnnotationConstants.SpatialIndex)); + Assert.Equal(true, designIndex[AnnotationConstants.SpatialIndex]); + Assert.Equal("geography", designIndex[AnnotationConstants.SpatialIndexType]); + } + + [Fact] + public void NamedSpatialIndexesOnSamePropertyRemainDistinct() + { + using var context = new NamedSpatialIndexesContext(CreateOptions()); + + var indexes = GetDesignModel(context).GetRelationalModel() + .FindTable("Places", schema: null)! + .Indexes + .OrderBy(index => index.Name) + .ToList(); + + Assert.Collection( + indexes, + index => + { + Assert.Equal("SIX_Places_Location_Coarse", index.Name); + Assert.Equal(16, index[AnnotationConstants.SpatialIndexCellsPerObject]); + }, + index => + { + Assert.Equal("SIX_Places_Location_Fine", index.Name); + Assert.Equal(64, index[AnnotationConstants.SpatialIndexCellsPerObject]); + }); + } + + [Fact] + public void OwnedNavigationSpatialIndexBuildsDesignModel() + { + using var context = new OwnedSpatialIndexContext(CreateOptions()); + + var index = Assert.Single( + GetDesignModel(context).GetRelationalModel().FindTable("OwnedLocations", schema: null)!.Indexes); + + Assert.Equal("SIX_OwnedLocations_Location", index.Name); + Assert.Equal(true, index[AnnotationConstants.SpatialIndex]); + Assert.Equal("geography", index[AnnotationConstants.SpatialIndexType]); + } + + [Fact] + public void GeometrySpatialIndexRequiresBoundingBox() + { + using var context = new GeometryWithoutBoundingBoxContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("requires a bounding box", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void GeographySpatialIndexRejectsBoundingBox() + { + using var context = new GeographyWithBoundingBoxContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("cannot have a bounding box", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsUnsupportedIndexShapes() + { + using var context = new InvalidIndexShapeContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("exactly one column", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRequiresClusteredPrimaryKey() + { + using var context = new NonClusteredPrimaryKeyContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("clustered primary key", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRequiresPrimaryKey() + { + using var context = new MissingPrimaryKeyContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("must have a primary key", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsUniqueIndex() + { + using var context = new UniqueSpatialIndexContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("cannot be unique", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsFilteredIndex() + { + using var context = new FilteredSpatialIndexContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("cannot have a filter", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsClusteredIndex() + { + using var context = new ClusteredSpatialIndexContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("cannot be clustered", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsIncludedColumns() + { + using var context = new IncludedColumnSpatialIndexContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("cannot have included columns", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsOnlineOption() + { + using var context = new OnlineSpatialIndexContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("does not support ONLINE", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsNonSpatialStoreType() + { + using var context = new NonSpatialStoreTypeContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("Only SQL Server geography and geometry columns are supported", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsConflictingOptionsOnMappedIndexes() + { + using var context = new ConflictingMappedIndexesContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("conflicting spatial index options", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsUnsupportedSqlServerIndexOptions() + { + using var context = new UnsupportedIndexOptionsContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("does not support additional SQL Server index options yet", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void SpatialIndexRejectsDescendingSortOrder() + { + using var context = new DescendingSpatialIndexContext(CreateOptions()); + + var exception = Assert.Throws(() => GetDesignModel(context).GetRelationalModel()); + + Assert.Contains("cannot specify sort order", exception.Message, StringComparison.Ordinal); + } + + private static DbContextOptions CreateOptions() + where TContext : DbContext + => new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + + private static IModel GetDesignModel(DbContext context) + => context.GetService().Model; + + private static void ConfigureSpatialModel( + ModelBuilder modelBuilder, + string storeType, + bool includeBoundingBox = false) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("Places", "odd]schema"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Location) + .HasColumnName("Location]") + .HasColumnType(storeType); + entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => + { + if (includeBoundingBox) + { + options.HasBoundingBox(-180.5, -90.25, 180.5, 90.25); + } + + options.HasCellsPerObject(32); + }) + .HasDatabaseName("SIX_Places_Location"); + } + + private sealed class GeographyContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, "geography"); + } + + private sealed class NamedSpatialIndexesContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("Places"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Location).HasColumnType("geography"); + entityBuilder.HasSpatialIndex( + entity => entity.Location, + "CoarseLocationSpatialIndex", + options => options.HasCellsPerObject(16)) + .HasDatabaseName("SIX_Places_Location_Coarse"); + entityBuilder.HasSpatialIndex( + entity => entity.Location, + "FineLocationSpatialIndex", + options => options.HasCellsPerObject(64)) + .HasDatabaseName("SIX_Places_Location_Fine"); + } + } + + private sealed class OwnedSpatialIndexContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entityBuilder => + { + entityBuilder.ToTable("SpatialOwners"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.OwnsOne( + entity => entity.Details, + ownedNavigationBuilder => + { + ownedNavigationBuilder.ToTable("OwnedLocations"); + ownedNavigationBuilder.Property(details => details.Location).HasColumnType("geography"); + ownedNavigationBuilder.HasSpatialIndex(details => details.Location) + .HasDatabaseName("SIX_OwnedLocations_Location"); + }); + }); + } + } + + private sealed class GeometryWithoutBoundingBoxContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, "geometry"); + } + + private sealed class GeographyWithBoundingBoxContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, "geography", includeBoundingBox: true); + } + + private sealed class InvalidIndexShapeContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("Places"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Location).HasColumnType("geography"); + entityBuilder.HasIndex(entity => new { entity.Location, entity.Id }) + .HasAnnotation(AnnotationConstants.SpatialIndex, true); + } + } + + private sealed class NonClusteredPrimaryKeyContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("Places"); + entityBuilder.HasKey(entity => entity.Id).IsClustered(false); + entityBuilder.Property(entity => entity.Location).HasColumnType("geography"); + entityBuilder.HasSpatialIndex(entity => entity.Location); + } + } + + private sealed class MissingPrimaryKeyContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("Places"); + entityBuilder.HasNoKey(); + entityBuilder.Property(entity => entity.Location).HasColumnType("geography"); + entityBuilder.HasSpatialIndex(entity => entity.Location); + } + } + + private sealed class UniqueSpatialIndexContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureSpatialModel(modelBuilder, "geography"); + modelBuilder.Entity().HasIndex(entity => entity.Location).IsUnique(); + } + } + + private sealed class FilteredSpatialIndexContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureSpatialModel(modelBuilder, "geography"); + modelBuilder.Entity().HasIndex(entity => entity.Location).HasFilter("[Id] > 0"); + } + } + + private sealed class ClusteredSpatialIndexContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureSpatialModel(modelBuilder, "geography"); + modelBuilder.Entity().HasIndex(entity => entity.Location).IsClustered(); + } + } + + private sealed class IncludedColumnSpatialIndexContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureSpatialModel(modelBuilder, "geography"); + modelBuilder.Entity() + .HasIndex(entity => entity.Location) + .IncludeProperties(entity => entity.Name); + } + } + + private sealed class OnlineSpatialIndexContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureSpatialModel(modelBuilder, "geography"); + modelBuilder.Entity().HasIndex(entity => entity.Location).IsCreatedOnline(); + } + } + + private sealed class UnsupportedIndexOptionsContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureSpatialModel(modelBuilder, "geography"); + modelBuilder.Entity() + .HasIndex(entity => entity.Location) + .HasFillFactor(80); + } + } + + private sealed class DescendingSpatialIndexContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureSpatialModel(modelBuilder, "geography"); + modelBuilder.Entity() + .HasIndex(entity => entity.Location) + .IsDescending(); + } + } + + private sealed class NonSpatialStoreTypeContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("Places"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Location).HasColumnType("nvarchar(100)"); + entityBuilder.HasSpatialIndex(entity => entity.Location); + } + } + + private sealed class ConflictingMappedIndexesContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entityBuilder => + { + entityBuilder.ToTable("SharedPlaces"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Location) + .HasColumnName("Location") + .HasColumnType("geography"); + entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => options.HasCellsPerObject(16)) + .HasDatabaseName("SIX_SharedPlaces_Location"); + entityBuilder.HasOne(entity => entity.Details) + .WithOne() + .HasForeignKey(entity => entity.Id); + }); + + modelBuilder.Entity(entityBuilder => + { + entityBuilder.ToTable("SharedPlaces"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Location) + .HasColumnName("Location") + .HasColumnType("geography"); + entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => options.HasCellsPerObject(32)) + .HasDatabaseName("SIX_SharedPlaces_Location"); + }); + } + } + + private sealed class SpatialEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public Point Location { get; set; } = new(0, 0) { SRID = 4326 }; + } + + private sealed class NonSpatialEntity + { + public int Id { get; set; } + public string Location { get; set; } = string.Empty; + } + + private sealed class SharedSpatialPrincipal + { + public int Id { get; set; } + public Point Location { get; set; } = new(0, 0) { SRID = 4326 }; + public SharedSpatialDetails Details { get; set; } = null!; + } + + private sealed class SharedSpatialDetails + { + public int Id { get; set; } + public Point Location { get; set; } = new(0, 0) { SRID = 4326 }; + } + + private sealed class SpatialOwner + { + public int Id { get; set; } + public OwnedSpatialEntity Details { get; set; } = new(); + } + + private sealed class OwnedSpatialEntity + { + public Point Location { get; set; } = new(0, 0) { SRID = 4326 }; + } +} diff --git a/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexSqlGeneratorTests.cs b/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexSqlGeneratorTests.cs new file mode 100644 index 0000000..9b87525 --- /dev/null +++ b/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexSqlGeneratorTests.cs @@ -0,0 +1,298 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using NetTopologySuite.Geometries; +using Xunit; + +namespace EntityFrameworkCore.Extensions.Tests; + +public sealed class SpatialIndexSqlGeneratorTests +{ + private const string ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=NotUsed"; + + [Fact] + public void GeographyModelPropagatesAnnotationsAndGeneratesSpatialSql() + { + using var context = CreateGeographyContext(); + var model = GetDesignModel(context); + var operations = context.GetService() + .GetDifferences(source: null, model.GetRelationalModel()); + + var createIndex = Assert.Single(operations.OfType()); + Assert.Equal(true, createIndex[AnnotationConstants.SpatialIndex]); + Assert.Equal("geography", createIndex[AnnotationConstants.SpatialIndexType]); + Assert.Equal(32, createIndex[AnnotationConstants.SpatialIndexCellsPerObject]); + + var command = Assert.Single( + GenerateCommands(context, operations, model), + candidate => candidate.CommandText.Contains("CREATE SPATIAL INDEX", StringComparison.Ordinal)); + Assert.Equal( + """ + CREATE SPATIAL INDEX [SIX_Places_Location] ON [odd]]schema].[Places] ([Location]]]) + USING GEOGRAPHY_AUTO_GRID + WITH (CELLS_PER_OBJECT = 32); + """, + command.CommandText.Trim()); + } + + [Fact] + public void GeometryModelGeneratesAutoGridSqlWithInvariantBoundingBox() + { + using var context = CreateGeometryContext(); + var model = GetDesignModel(context); + var operations = context.GetService() + .GetDifferences(source: null, model.GetRelationalModel()); + + var createIndex = Assert.Single(operations.OfType()); + Assert.Equal("geometry", createIndex[AnnotationConstants.SpatialIndexType]); + + var command = Assert.Single( + GenerateCommands(context, operations, model), + candidate => candidate.CommandText.Contains("CREATE SPATIAL INDEX", StringComparison.Ordinal)); + Assert.Equal( + """ + CREATE SPATIAL INDEX [SIX_Places_Location] ON [odd]]schema].[Places] ([Location]]]) + USING GEOMETRY_AUTO_GRID + WITH (BOUNDING_BOX = (-180.5, -90.25, 180.5, 90.25), CELLS_PER_OBJECT = 64); + """, + command.CommandText.Trim()); + } + + [Fact] + public void ChangingSpatialOptionsDropsAndRecreatesIndex() + { + using var sourceContext = CreateGeographyContext(); + using var targetContext = CreateChangedGeographyContext(); + var sourceModel = GetDesignModel(sourceContext); + var targetModel = GetDesignModel(targetContext); + + var operations = targetContext.GetService().GetDifferences( + sourceModel.GetRelationalModel(), + targetModel.GetRelationalModel()); + + Assert.Collection( + operations, + operation => Assert.IsType(operation), + operation => + { + var createIndex = Assert.IsType(operation); + Assert.Equal(128, createIndex[AnnotationConstants.SpatialIndexCellsPerObject]); + }); + + var commands = GenerateCommands(targetContext, operations, targetModel); + Assert.Contains("DROP INDEX [SIX_Places_Location] ON [odd]]schema].[Places];", commands[0].CommandText); + Assert.Contains("CELLS_PER_OBJECT = 128", commands[1].CommandText); + } + + [Fact] + public void RemovingSpatialIndexUsesEfCoreDropIndexOperation() + { + using var sourceContext = CreateGeographyContext(); + using var targetContext = CreateNoIndexContext(); + var sourceModel = GetDesignModel(sourceContext); + var targetModel = GetDesignModel(targetContext); + + var operations = targetContext.GetService().GetDifferences( + sourceModel.GetRelationalModel(), + targetModel.GetRelationalModel()); + + var dropIndex = Assert.Single(operations); + Assert.IsType(dropIndex); + var command = Assert.Single(GenerateCommands(targetContext, operations, targetModel)); + Assert.Equal( + "DROP INDEX [SIX_Places_Location] ON [odd]]schema].[Places];", + command.CommandText.Trim()); + } + + [Fact] + public void RenamingSpatialIndexUsesEfCoreRenameIndexOperation() + { + using var sourceContext = CreateGeographyContext(); + using var targetContext = CreateRenamedGeographyContext(); + var sourceModel = GetDesignModel(sourceContext); + var targetModel = GetDesignModel(targetContext); + + var operations = targetContext.GetService().GetDifferences( + sourceModel.GetRelationalModel(), + targetModel.GetRelationalModel()); + + var renameIndex = Assert.IsType(Assert.Single(operations)); + Assert.Equal("SIX_Places_Location", renameIndex.Name); + Assert.Equal("SIX_Places_Location_Renamed", renameIndex.NewName); + var command = Assert.Single(GenerateCommands(targetContext, operations, targetModel)); + Assert.Contains("sp_rename", command.CommandText, StringComparison.Ordinal); + Assert.Contains("SIX_Places_Location_Renamed", command.CommandText, StringComparison.Ordinal); + } + + [Fact] + public void RawSpatialIndexOperationRejectsEmptyDescendingArray() + { + using var context = CreateGeographyContext(); + var operation = CreateSpatialIndexOperation(); + operation.IsDescending = []; + + var exception = Assert.Throws( + () => GenerateCommands(context, [operation], model: null)); + + Assert.Contains("cannot specify sort order", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void RawSpatialIndexOperationRejectsOnlineAnnotation() + { + using var context = CreateGeographyContext(); + var operation = CreateSpatialIndexOperation(); + operation.AddAnnotation("SqlServer:Online", true); + + var exception = Assert.Throws( + () => GenerateCommands(context, [operation], model: null)); + + Assert.Contains("does not support ONLINE", exception.Message, StringComparison.Ordinal); + } + + private static GeographyContext CreateGeographyContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + return new GeographyContext(options); + } + + private static ChangedGeographyContext CreateChangedGeographyContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + return new ChangedGeographyContext(options); + } + + private static GeometryContext CreateGeometryContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + return new GeometryContext(options); + } + + private static RenamedGeographyContext CreateRenamedGeographyContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + return new RenamedGeographyContext(options); + } + + private static NoIndexContext CreateNoIndexContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + return new NoIndexContext(options); + } + + private static IModel GetDesignModel(DbContext context) + => context.GetService().Model; + + private static IReadOnlyList GenerateCommands( + DbContext context, + IReadOnlyList operations, + IModel? model) + => context.GetService().Generate(operations, model); + + private static CreateIndexOperation CreateSpatialIndexOperation() + { + var operation = new CreateIndexOperation + { + Name = "SIX_Places_Location", + Table = "Places", + Columns = ["Location"] + }; + operation.AddAnnotation(AnnotationConstants.SpatialIndex, true); + operation.AddAnnotation(AnnotationConstants.SpatialIndexType, "geography"); + return operation; + } + + private static void ConfigureSpatialModel( + ModelBuilder modelBuilder, + string storeType, + int? cellsPerObject, + bool includeIndex = true, + bool includeBoundingBox = false) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("Places", "odd]schema"); + entityBuilder.HasKey(entity => entity.Id); + entityBuilder.Property(entity => entity.Location) + .HasColumnName("Location]") + .HasColumnType(storeType); + + if (!includeIndex) + { + return; + } + + entityBuilder.HasSpatialIndex( + entity => entity.Location, + options => + { + if (includeBoundingBox) + { + options.HasBoundingBox(-180.5, -90.25, 180.5, 90.25); + } + + if (cellsPerObject is not null) + { + options.HasCellsPerObject(cellsPerObject.Value); + } + }) + .HasDatabaseName("SIX_Places_Location"); + } + + private sealed class GeographyContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, "geography", 32); + } + + private sealed class ChangedGeographyContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, "geography", 128); + } + + private sealed class GeometryContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, "geometry", 64, includeBoundingBox: true); + } + + private sealed class RenamedGeographyContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ConfigureSpatialModel(modelBuilder, "geography", 32); + modelBuilder.Entity() + .HasIndex(entity => entity.Location) + .HasDatabaseName("SIX_Places_Location_Renamed"); + } + } + + private sealed class NoIndexContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, "geography", cellsPerObject: null, includeIndex: false); + } + + private sealed class SpatialEntity + { + public int Id { get; set; } + public Point Location { get; set; } = new(0, 0) { SRID = 4326 }; + } +} diff --git a/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexSqlServerIntegrationTests.cs b/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexSqlServerIntegrationTests.cs new file mode 100644 index 0000000..97c48ab --- /dev/null +++ b/EntityFrameworkCore.Extensions.Tests/Features/SpatialIndexes/SpatialIndexSqlServerIntegrationTests.cs @@ -0,0 +1,288 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using NetTopologySuite.Geometries; +using static EntityFrameworkCore.Extensions.Tests.SqlServerIntegrationTestDatabase; +using Xunit; + +namespace EntityFrameworkCore.Extensions.Tests; + +public sealed class SpatialIndexSqlServerIntegrationTests +{ + public static bool HasSqlServerConnectionString + => IsConfigured; + + [Fact( + Skip = $"Set {ConnectionStringEnvironmentVariable} to run SQL Server integration tests.", + SkipUnless = nameof(HasSqlServerConnectionString), + Timeout = 120_000)] + public async Task SpatialIndexesCreateChangeAndDropOnSqlServer() + { + var cancellationToken = Xunit.TestContext.Current.CancellationToken; + var databaseName = $"EfCoreExtensions_{Guid.NewGuid():N}"; + var connectionString = await CreateDatabaseConnectionStringAsync(databaseName, cancellationToken); + await using var spatialContext = CreateSpatialContext(connectionString); + await using var changedSpatialContext = CreateChangedSpatialContext(connectionString); + await using var noSpatialIndexesContext = CreateNoSpatialIndexesContext(connectionString); + + try + { + Assert.True(await spatialContext.Database.EnsureCreatedAsync(cancellationToken)); + Assert.Equal(2, await GetSpatialIndexCountAsync(spatialContext, cancellationToken)); + Assert.Equal( + "GEOGRAPHY_AUTO_GRID", + await GetSpatialIndexTessellationAsync( + spatialContext, + "SIX_SpatialPlaces_GeographyLocation", + cancellationToken)); + Assert.Equal( + "GEOMETRY_AUTO_GRID", + await GetSpatialIndexTessellationAsync( + spatialContext, + "SIX_SpatialPlaces_GeometryLocation", + cancellationToken)); + Assert.Equal( + 32, + await GetSpatialIndexCellsPerObjectAsync( + spatialContext, + "SIX_SpatialPlaces_GeographyLocation", + cancellationToken)); + Assert.Equal( + -1000d, + await GetSpatialIndexBoundingBoxXMinAsync( + spatialContext, + "SIX_SpatialPlaces_GeometryLocation", + cancellationToken)); + + var connection = spatialContext.Database.GetDbConnection(); + await ExecuteNonQueryAsync( + connection, + """ + INSERT INTO [dbo].[SpatialPlaces] ([GeographyLocation], [GeometryLocation]) + VALUES ( + geography::Point(52.3702, 4.8952, 4326), + geometry::Point(0, 0, 0)); + """, + cancellationToken); + + const string indexedSpatialQuery = """ + SELECT TOP (1) [place].[Id] + FROM [dbo].[SpatialPlaces] AS [place] + WITH (INDEX([SIX_SpatialPlaces_GeographyLocation])) + WHERE [place].[GeographyLocation].STDistance( + geography::Point(52.3702, 4.8952, 4326)) <= 1000; + """; + Assert.Equal( + 1, + Convert.ToInt32( + await ExecuteScalarAsync(connection, indexedSpatialQuery, cancellationToken), + System.Globalization.CultureInfo.InvariantCulture)); + + var showplan = await GetEstimatedExecutionPlanAsync( + connection, + indexedSpatialQuery, + cancellationToken); + Assert.Contains( + "Index=\"[SIX_SpatialPlaces_GeographyLocation]\"", + showplan, + StringComparison.Ordinal); + Assert.Contains("IndexKind=\"Spatial\"", showplan, StringComparison.Ordinal); + + var spatialModel = spatialContext.GetService().Model; + var changedSpatialModel = changedSpatialContext.GetService().Model; + var changeOperations = changedSpatialContext.GetService().GetDifferences( + spatialModel.GetRelationalModel(), + changedSpatialModel.GetRelationalModel()); + Assert.Single(changeOperations.OfType()); + Assert.Single(changeOperations.OfType()); + await ExecuteMigrationOperationsAsync( + changedSpatialContext, + changeOperations, + changedSpatialModel, + cancellationToken); + Assert.Equal( + 128, + await GetSpatialIndexCellsPerObjectAsync( + changedSpatialContext, + "SIX_SpatialPlaces_GeographyLocation", + cancellationToken)); + + var noSpatialIndexesModel = noSpatialIndexesContext.GetService().Model; + var removeOperations = noSpatialIndexesContext.GetService().GetDifferences( + changedSpatialModel.GetRelationalModel(), + noSpatialIndexesModel.GetRelationalModel()); + Assert.Equal(2, removeOperations.OfType().Count()); + await ExecuteMigrationOperationsAsync( + noSpatialIndexesContext, + removeOperations, + noSpatialIndexesModel, + cancellationToken); + Assert.Equal(0, await GetSpatialIndexCountAsync(noSpatialIndexesContext, cancellationToken)); + } + finally + { + await spatialContext.Database.EnsureDeletedAsync(cancellationToken); + } + } + + private static SpatialDatabaseContext CreateSpatialContext(string connectionString) + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + return new SpatialDatabaseContext(options); + } + + private static ChangedSpatialDatabaseContext CreateChangedSpatialContext(string connectionString) + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + return new ChangedSpatialDatabaseContext(options); + } + + private static NoSpatialIndexesDatabaseContext CreateNoSpatialIndexesContext(string connectionString) + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString, sqlServer => sqlServer.UseNetTopologySuite()) + .UseEntityFrameworkCoreExtensions() + .Options; + return new NoSpatialIndexesDatabaseContext(options); + } + + private static async Task GetSpatialIndexCountAsync( + DbContext context, + CancellationToken cancellationToken) + => Convert.ToInt32( + await ExecuteSpatialIndexScalarAsync( + context, + "COUNT(*)", + indexName: null, + cancellationToken), + System.Globalization.CultureInfo.InvariantCulture); + + private static async Task GetSpatialIndexTessellationAsync( + DbContext context, + string indexName, + CancellationToken cancellationToken) + => (string?)await ExecuteSpatialIndexScalarAsync( + context, + "spatial_index.tessellation_scheme", + indexName, + cancellationToken); + + private static async Task GetSpatialIndexCellsPerObjectAsync( + DbContext context, + string indexName, + CancellationToken cancellationToken) + => Convert.ToInt32( + await ExecuteSpatialIndexScalarAsync( + context, + "tessellation.cells_per_object", + indexName, + cancellationToken), + System.Globalization.CultureInfo.InvariantCulture); + + private static async Task GetSpatialIndexBoundingBoxXMinAsync( + DbContext context, + string indexName, + CancellationToken cancellationToken) + => Convert.ToDouble( + await ExecuteSpatialIndexScalarAsync( + context, + "tessellation.bounding_box_xmin", + indexName, + cancellationToken), + System.Globalization.CultureInfo.InvariantCulture); + + private static async Task ExecuteSpatialIndexScalarAsync( + DbContext context, + string selection, + string? indexName, + CancellationToken cancellationToken) + { + var connection = context.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + + await using var command = connection.CreateCommand(); + command.CommandText = $""" + SELECT {selection} + FROM sys.spatial_indexes AS spatial_index + INNER JOIN sys.spatial_index_tessellations AS tessellation + ON tessellation.object_id = spatial_index.object_id + AND tessellation.index_id = spatial_index.index_id + INNER JOIN sys.tables AS table_definition ON table_definition.object_id = spatial_index.object_id + INNER JOIN sys.schemas AS schema_definition ON schema_definition.schema_id = table_definition.schema_id + WHERE schema_definition.name = N'dbo' + AND table_definition.name = N'SpatialPlaces' + AND (@index_name IS NULL OR spatial_index.name = @index_name); + """; + var parameter = command.CreateParameter(); + parameter.ParameterName = "@index_name"; + parameter.Value = (object?)indexName ?? DBNull.Value; + command.Parameters.Add(parameter); + return await command.ExecuteScalarAsync(cancellationToken); + } + + private static void ConfigureSpatialModel( + ModelBuilder modelBuilder, + int geographyCellsPerObject, + bool includeIndexes) + { + var entityBuilder = modelBuilder.Entity(); + entityBuilder.ToTable("SpatialPlaces"); + entityBuilder.HasKey(place => place.Id); + entityBuilder.Property(place => place.GeographyLocation).HasColumnType("geography"); + entityBuilder.Property(place => place.GeometryLocation).HasColumnType("geometry"); + + if (!includeIndexes) + { + return; + } + + entityBuilder.HasSpatialIndex( + place => place.GeographyLocation, + options => options.HasCellsPerObject(geographyCellsPerObject)) + .HasDatabaseName("SIX_SpatialPlaces_GeographyLocation"); + entityBuilder.HasSpatialIndex( + place => place.GeometryLocation, + options => options + .HasBoundingBox(-1000, -500, 1000, 500) + .HasCellsPerObject(64)) + .HasDatabaseName("SIX_SpatialPlaces_GeometryLocation"); + } + + private sealed class SpatialDatabaseContext(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, geographyCellsPerObject: 32, includeIndexes: true); + } + + private sealed class ChangedSpatialDatabaseContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, geographyCellsPerObject: 128, includeIndexes: true); + } + + private sealed class NoSpatialIndexesDatabaseContext(DbContextOptions options) + : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => ConfigureSpatialModel(modelBuilder, geographyCellsPerObject: 32, includeIndexes: false); + } + + private sealed class SpatialPlace + { + public int Id { get; set; } + public Point GeographyLocation { get; set; } = new(0, 0) { SRID = 4326 }; + public Point GeometryLocation { get; set; } = new(0, 0); + } +} diff --git a/EntityFrameworkCore.Extensions.Tests/CustomSql/TestSql.sql b/EntityFrameworkCore.Extensions.Tests/Shared/CustomSql/TestSql.sql similarity index 100% rename from EntityFrameworkCore.Extensions.Tests/CustomSql/TestSql.sql rename to EntityFrameworkCore.Extensions.Tests/Shared/CustomSql/TestSql.sql diff --git a/EntityFrameworkCore.Extensions.Tests/DatabaseFacadeExtensionsTests.cs b/EntityFrameworkCore.Extensions.Tests/Shared/DatabaseFacadeExtensionsTests.cs similarity index 100% rename from EntityFrameworkCore.Extensions.Tests/DatabaseFacadeExtensionsTests.cs rename to EntityFrameworkCore.Extensions.Tests/Shared/DatabaseFacadeExtensionsTests.cs diff --git a/EntityFrameworkCore.Extensions.Tests/DbContextOptionsBuilderExtensionsTest.cs b/EntityFrameworkCore.Extensions.Tests/Shared/DbContextOptionsBuilderExtensionsTest.cs similarity index 100% rename from EntityFrameworkCore.Extensions.Tests/DbContextOptionsBuilderExtensionsTest.cs rename to EntityFrameworkCore.Extensions.Tests/Shared/DbContextOptionsBuilderExtensionsTest.cs diff --git a/EntityFrameworkCore.Extensions.Tests/MigrationBuilderExtensionsTests.cs b/EntityFrameworkCore.Extensions.Tests/Shared/MigrationBuilderExtensionsTests.cs similarity index 100% rename from EntityFrameworkCore.Extensions.Tests/MigrationBuilderExtensionsTests.cs rename to EntityFrameworkCore.Extensions.Tests/Shared/MigrationBuilderExtensionsTests.cs diff --git a/EntityFrameworkCore.Extensions.Tests/ModelBuilderExtensionsTests.cs b/EntityFrameworkCore.Extensions.Tests/Shared/ModelBuilderExtensionsTests.cs similarity index 100% rename from EntityFrameworkCore.Extensions.Tests/ModelBuilderExtensionsTests.cs rename to EntityFrameworkCore.Extensions.Tests/Shared/ModelBuilderExtensionsTests.cs diff --git a/EntityFrameworkCore.Extensions.Tests/Shared/SqlServerIntegrationTestDatabase.cs b/EntityFrameworkCore.Extensions.Tests/Shared/SqlServerIntegrationTestDatabase.cs new file mode 100644 index 0000000..b9e9bcd --- /dev/null +++ b/EntityFrameworkCore.Extensions.Tests/Shared/SqlServerIntegrationTestDatabase.cs @@ -0,0 +1,109 @@ +using System.Data.Common; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace EntityFrameworkCore.Extensions.Tests; + +internal static class SqlServerIntegrationTestDatabase +{ + public const string ConnectionStringEnvironmentVariable = "EFCORE_EXTENSIONS_SQLSERVER"; + + public static bool IsConfigured + => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable)); + + public static async Task CreateDatabaseConnectionStringAsync( + string databaseName, + CancellationToken cancellationToken) + { + var configuredConnectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable)!; + var masterConnectionString = new SqlConnectionStringBuilder(configuredConnectionString) + { + InitialCatalog = "master", + TrustServerCertificate = true + }.ConnectionString; + + await WaitForSqlServerAsync(masterConnectionString, cancellationToken); + + return new SqlConnectionStringBuilder(masterConnectionString) + { + InitialCatalog = databaseName + }.ConnectionString; + } + + public static async Task ExecuteMigrationOperationsAsync( + DbContext context, + IReadOnlyList operations, + IModel model, + CancellationToken cancellationToken) + { + var connection = context.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + + var commands = context.GetService().Generate(operations, model); + foreach (var migrationCommand in commands) + { + await ExecuteNonQueryAsync(connection, migrationCommand.CommandText, cancellationToken); + } + } + + public static async Task ExecuteScalarAsync( + DbConnection connection, + string commandText, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = commandText; + return await command.ExecuteScalarAsync(cancellationToken); + } + + public static async Task GetEstimatedExecutionPlanAsync( + DbConnection connection, + string commandText, + CancellationToken cancellationToken) + { + await ExecuteNonQueryAsync(connection, "SET SHOWPLAN_XML ON;", cancellationToken); + try + { + return (string)(await ExecuteScalarAsync(connection, commandText, cancellationToken))!; + } + finally + { + await ExecuteNonQueryAsync(connection, "SET SHOWPLAN_XML OFF;", cancellationToken); + } + } + + public static async Task ExecuteNonQueryAsync( + DbConnection connection, + string commandText, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = commandText; + _ = await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static async Task WaitForSqlServerAsync(string connectionString, CancellationToken cancellationToken) + { + const int maximumAttempts = 60; + for (var attempt = 1; attempt <= maximumAttempts; attempt++) + { + try + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + return; + } + catch (SqlException) when (attempt < maximumAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + } +} diff --git a/EntityFrameworkCore.Extensions.Tests/TestContext.cs b/EntityFrameworkCore.Extensions.Tests/Shared/TestContext.cs similarity index 100% rename from EntityFrameworkCore.Extensions.Tests/TestContext.cs rename to EntityFrameworkCore.Extensions.Tests/Shared/TestContext.cs diff --git a/EntityFrameworkCore.Extensions.Tests/packages.lock.json b/EntityFrameworkCore.Extensions.Tests/packages.lock.json index 4c696a7..bedf07b 100644 --- a/EntityFrameworkCore.Extensions.Tests/packages.lock.json +++ b/EntityFrameworkCore.Extensions.Tests/packages.lock.json @@ -26,6 +26,21 @@ "Microsoft.Extensions.Logging": "10.0.11" } }, + "Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite": { + "type": "Direct", + "requested": "[10.0.11, 11.0.0)", + "resolved": "10.0.11", + "contentHash": "wG2z6o6vDnLmT3cgEyu1MyGEQkg+Mff6E9Y8MIV0h4oSSamj/PeEUZLkgaw/qJsfNJvCBXAmWu4Ywsk98CDwuA==", + "dependencies": { + "Microsoft.Data.SqlClient": "6.1.6", + "Microsoft.EntityFrameworkCore.SqlServer": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "NetTopologySuite": "2.6.0", + "NetTopologySuite.IO.SqlServerBytes": "2.1.0" + } + }, "xunit.v3": { "type": "Direct", "requested": "[4.0.0, )", @@ -311,6 +326,19 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, + "NetTopologySuite": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "1B1OTacTd4QtFyBeuIOcThwSSLUdRZU3bSFIwM8vk36XiZlBMi3K36u74e4OqwwHRHUuJC1PhbDx4hyI266X1Q==" + }, + "NetTopologySuite.IO.SqlServerBytes": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "R4BcV19f2l6EjHSjh/EHwLhYQHrOov9vig1EW5oBm0iqlZOgaIJm5tBnlbFnYlvdYOPuf5p0Qtf8PCVwH77Wbg==", + "dependencies": { + "NetTopologySuite": "[2.0.0, 3.0.0-A)" + } + }, "System.ClientModel": { "type": "Transitive", "resolved": "1.8.0", diff --git a/EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj b/EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj index 2c067c5..c3b2878 100644 --- a/EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj +++ b/EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj @@ -6,14 +6,14 @@ 10.0.0 Nikita Savinov Nikita Savinov - SQL Server dynamic data masking and migration helpers for EF Core 10. + SQL Server spatial indexes, dynamic data masking, and migration helpers for EF Core 10. https://github.com/nikitasavinov/EntityFrameworkCore.Extensions https://github.com/nikitasavinov/EntityFrameworkCore.Extensions git MIT false README.md - EntityFrameworkCore;EntityFramework;entity-framework-core;EFCore;SQLServer;dynamic-data-masking;data-masking;fluent-api;migrations;sql-migrations + EntityFrameworkCore;EntityFramework;entity-framework-core;EFCore;SQLServer;spatial;spatial-index;geography;geometry;dynamic-data-masking;data-masking;fluent-api;migrations;sql-migrations true true snupkg diff --git a/EntityFrameworkCore.Extensions/AnnotationConstants.cs b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/AnnotationConstants.DynamicDataMasking.cs similarity index 60% rename from EntityFrameworkCore.Extensions/AnnotationConstants.cs rename to EntityFrameworkCore.Extensions/Features/DynamicDataMasking/AnnotationConstants.DynamicDataMasking.cs index ef6e79f..f4d27d9 100644 --- a/EntityFrameworkCore.Extensions/AnnotationConstants.cs +++ b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/AnnotationConstants.DynamicDataMasking.cs @@ -1,9 +1,6 @@ namespace EntityFrameworkCore.Extensions; -/// -/// Contains annotation names used by EntityFrameworkCore.Extensions. -/// -public static class AnnotationConstants +public static partial class AnnotationConstants { /// /// Identifies a SQL Server dynamic data masking annotation. diff --git a/EntityFrameworkCore.Extensions/Services/DynamicDataMaskingAnnotation.cs b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/DynamicDataMaskingAnnotation.cs similarity index 100% rename from EntityFrameworkCore.Extensions/Services/DynamicDataMaskingAnnotation.cs rename to EntityFrameworkCore.Extensions/Features/DynamicDataMasking/DynamicDataMaskingAnnotation.cs diff --git a/EntityFrameworkCore.Extensions/Services/ExtendedSqlServerAnnotationProvider.cs b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/ExtendedSqlServerAnnotationProvider.DynamicDataMasking.cs similarity index 75% rename from EntityFrameworkCore.Extensions/Services/ExtendedSqlServerAnnotationProvider.cs rename to EntityFrameworkCore.Extensions/Features/DynamicDataMasking/ExtendedSqlServerAnnotationProvider.DynamicDataMasking.cs index c48464b..8e5357e 100644 --- a/EntityFrameworkCore.Extensions/Services/ExtendedSqlServerAnnotationProvider.cs +++ b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/ExtendedSqlServerAnnotationProvider.DynamicDataMasking.cs @@ -1,22 +1,12 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; namespace EntityFrameworkCore.Extensions.Services; #pragma warning disable EF1001 // Extending SQL Server's annotation provider requires its provider-internal implementation. -/// -/// Propagates EntityFrameworkCore.Extensions annotations to the SQL Server relational model. -/// -internal sealed class ExtendedSqlServerAnnotationProvider : SqlServerAnnotationProvider +internal sealed partial class ExtendedSqlServerAnnotationProvider { - /// Initializes a new annotation provider instance. - /// The relational annotation provider dependencies. - public ExtendedSqlServerAnnotationProvider(RelationalAnnotationProviderDependencies dependencies) : base(dependencies) - { - } - /// public override IEnumerable For(IColumn column, bool designTime) { diff --git a/EntityFrameworkCore.Extensions/Services/ExtendedSqlServerMigrationsSqlGenerator.cs b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/ExtendedSqlServerMigrationsSqlGenerator.DynamicDataMasking.cs similarity index 87% rename from EntityFrameworkCore.Extensions/Services/ExtendedSqlServerMigrationsSqlGenerator.cs rename to EntityFrameworkCore.Extensions/Features/DynamicDataMasking/ExtendedSqlServerMigrationsSqlGenerator.DynamicDataMasking.cs index e190316..238d7fe 100644 --- a/EntityFrameworkCore.Extensions/Services/ExtendedSqlServerMigrationsSqlGenerator.cs +++ b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/ExtendedSqlServerMigrationsSqlGenerator.DynamicDataMasking.cs @@ -2,25 +2,11 @@ using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations.Operations; using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.EntityFrameworkCore.Update; namespace EntityFrameworkCore.Extensions.Services; -/// -/// Generates SQL Server migration commands for EntityFrameworkCore.Extensions annotations. -/// -internal sealed class ExtendedSqlServerMigrationsSqlGenerator : SqlServerMigrationsSqlGenerator +internal sealed partial class ExtendedSqlServerMigrationsSqlGenerator { - /// Initializes a new generator instance. - /// The relational migration SQL dependencies. - /// The SQL Server modification-command batch preparer. - public ExtendedSqlServerMigrationsSqlGenerator( - MigrationsSqlGeneratorDependencies dependencies, - ICommandBatchPreparer commandBatchPreparer) - : base(dependencies, commandBatchPreparer) - { - } - /// protected override void Generate( CreateTableOperation operation, @@ -125,16 +111,24 @@ private void GenerateDropMaskingStatement( { var sqlHelper = Dependencies.SqlGenerationHelper; var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string)); + + // SQL Server validates DROP MASKED while compiling a batch, even when an IF condition is false. + // Dynamic SQL defers that validation until the catalog check confirms that the mask still exists. builder.Append("IF EXISTS (SELECT 1 FROM [sys].[masked_columns] WHERE [object_id] = OBJECT_ID(") .Append(stringTypeMapping.GenerateSqlLiteral( sqlHelper.DelimitIdentifier(operation.Table, operation.Schema))) .Append(") AND [name] = ") .Append(stringTypeMapping.GenerateSqlLiteral(operation.Name)) .Append(" AND [is_masked] = 1)") - .AppendLine(); - - AppendAlterColumn(operation, builder); - builder.Append(" DROP MASKED") + .AppendLine() + .AppendLine("BEGIN") + .Append(" EXEC(") + .Append(stringTypeMapping.GenerateSqlLiteral( + $"ALTER TABLE {sqlHelper.DelimitIdentifier(operation.Table, operation.Schema)} " + + $"ALTER COLUMN {sqlHelper.DelimitIdentifier(operation.Name)} DROP MASKED" + + sqlHelper.StatementTerminator)) + .AppendLine(");") + .Append("END") .Append(sqlHelper.StatementTerminator) .EndCommand(); } diff --git a/EntityFrameworkCore.Extensions/DynamicDataMasking/MaskingFunctions.cs b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/MaskingFunctions.cs similarity index 100% rename from EntityFrameworkCore.Extensions/DynamicDataMasking/MaskingFunctions.cs rename to EntityFrameworkCore.Extensions/Features/DynamicDataMasking/MaskingFunctions.cs diff --git a/EntityFrameworkCore.Extensions/PropertyBuilderExtensions.cs b/EntityFrameworkCore.Extensions/Features/DynamicDataMasking/PropertyBuilderExtensions.cs similarity index 100% rename from EntityFrameworkCore.Extensions/PropertyBuilderExtensions.cs rename to EntityFrameworkCore.Extensions/Features/DynamicDataMasking/PropertyBuilderExtensions.cs diff --git a/EntityFrameworkCore.Extensions/Features/SpatialIndexes/AnnotationConstants.SpatialIndexes.cs b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/AnnotationConstants.SpatialIndexes.cs new file mode 100644 index 0000000..71cd021 --- /dev/null +++ b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/AnnotationConstants.SpatialIndexes.cs @@ -0,0 +1,39 @@ +namespace EntityFrameworkCore.Extensions; + +public static partial class AnnotationConstants +{ + /// + /// Identifies a SQL Server spatial index. + /// + public const string SpatialIndex = "EntityFrameworkCore.Extensions:SpatialIndex"; + + /// + /// Identifies the SQL Server spatial type used by a spatial index. + /// + public const string SpatialIndexType = "EntityFrameworkCore.Extensions:SpatialIndexType"; + + /// + /// Identifies the minimum X coordinate of a geometry spatial index bounding box. + /// + public const string SpatialIndexBoundingBoxXMin = "EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxXMin"; + + /// + /// Identifies the minimum Y coordinate of a geometry spatial index bounding box. + /// + public const string SpatialIndexBoundingBoxYMin = "EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxYMin"; + + /// + /// Identifies the maximum X coordinate of a geometry spatial index bounding box. + /// + public const string SpatialIndexBoundingBoxXMax = "EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxXMax"; + + /// + /// Identifies the maximum Y coordinate of a geometry spatial index bounding box. + /// + public const string SpatialIndexBoundingBoxYMax = "EntityFrameworkCore.Extensions:SpatialIndexBoundingBoxYMax"; + + /// + /// Identifies the cells-per-object setting of a SQL Server spatial index. + /// + public const string SpatialIndexCellsPerObject = "EntityFrameworkCore.Extensions:SpatialIndexCellsPerObject"; +} diff --git a/EntityFrameworkCore.Extensions/Features/SpatialIndexes/EntityTypeBuilderExtensions.cs b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/EntityTypeBuilderExtensions.cs new file mode 100644 index 0000000..23d584b --- /dev/null +++ b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/EntityTypeBuilderExtensions.cs @@ -0,0 +1,227 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace EntityFrameworkCore.Extensions; + +/// +/// Provides SQL Server configuration helpers for entity and owned entity types. +/// +public static class EntityTypeBuilderExtensions +{ + /// + /// Configures a SQL Server spatial index for the selected property. + /// + /// The entity type being configured. + /// The entity type builder. + /// An expression selecting the spatial property. + /// An optional action that configures spatial index options. + /// The index builder so that HasDatabaseName() can be chained. + /// + /// Map the property explicitly to geography or geometry. The entity must have a primary key + /// backed by a clustered SQL Server index. Unique, filtered, clustered, included-column, descending, and + /// other SQL Server index options are not supported for spatial indexes. + /// + public static IndexBuilder HasSpatialIndex( + this EntityTypeBuilder entityTypeBuilder, + Expression> propertyExpression, + Action? configure = null) + where TEntity : class + { + ArgumentNullException.ThrowIfNull(entityTypeBuilder); + ArgumentNullException.ThrowIfNull(propertyExpression); + + return ConfigureSpatialIndex(entityTypeBuilder.HasIndex(propertyExpression), configure); + } + + /// + /// Configures a named SQL Server spatial index for the selected property. + /// + /// The entity type being configured. + /// The entity type builder. + /// An expression selecting the spatial property. + /// The EF Core model index name. + /// An optional action that configures spatial index options. + /// The index builder so that HasDatabaseName() can be chained. + /// + /// Map the property explicitly to geography or geometry. The table must have a primary key + /// backed by a clustered SQL Server index. Other index options are not supported for spatial indexes. + /// Use a distinct model index name for each spatial index on the same property. Use + /// HasDatabaseName() to configure the corresponding SQL index name. + /// + public static IndexBuilder HasSpatialIndex( + this EntityTypeBuilder entityTypeBuilder, + Expression> propertyExpression, + string modelIndexName, + Action? configure = null) + where TEntity : class + { + ArgumentNullException.ThrowIfNull(entityTypeBuilder); + ArgumentNullException.ThrowIfNull(propertyExpression); + ArgumentException.ThrowIfNullOrWhiteSpace(modelIndexName); + + return ConfigureSpatialIndex(entityTypeBuilder.HasIndex(propertyExpression, modelIndexName), configure); + } + + /// + /// Configures a SQL Server spatial index for the named property. + /// + /// The entity type builder. + /// The spatial property name. + /// An optional action that configures spatial index options. + /// The index builder so that HasDatabaseName() can be chained. + /// + /// Map the property explicitly to geography or geometry. The entity must have a primary key + /// backed by a clustered SQL Server index. Unique, filtered, clustered, included-column, descending, and + /// other SQL Server index options are not supported for spatial indexes. + /// + public static IndexBuilder HasSpatialIndex( + this EntityTypeBuilder entityTypeBuilder, + string propertyName, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(entityTypeBuilder); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + + return ConfigureSpatialIndex(entityTypeBuilder.HasIndex(propertyName), configure); + } + + /// + /// Configures a named SQL Server spatial index for the named property. + /// + /// The entity type builder. + /// The spatial property name. + /// The EF Core model index name. + /// An optional action that configures spatial index options. + /// The index builder so that HasDatabaseName() can be chained. + /// + /// Map the property explicitly to geography or geometry. The table must have a primary key + /// backed by a clustered SQL Server index. Other index options are not supported for spatial indexes. + /// + public static IndexBuilder HasSpatialIndex( + this EntityTypeBuilder entityTypeBuilder, + string propertyName, + string modelIndexName, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(entityTypeBuilder); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + ArgumentException.ThrowIfNullOrWhiteSpace(modelIndexName); + + return ConfigureSpatialIndex(entityTypeBuilder.HasIndex([propertyName], modelIndexName), configure); + } + + /// + /// Configures a SQL Server spatial index for a property of an owned entity. + /// + /// The owner entity type. + /// The owned entity type. + /// The owned-navigation builder. + /// An expression selecting the spatial property. + /// An optional action that configures spatial index options. + /// The index builder so that HasDatabaseName() can be chained. + /// + /// Map the property explicitly to geography or geometry. The mapped table must have a primary key + /// backed by a clustered SQL Server index. Other index options are not supported for spatial indexes. + /// + public static IndexBuilder HasSpatialIndex( + this OwnedNavigationBuilder ownedNavigationBuilder, + Expression> propertyExpression, + Action? configure = null) + where TOwnerEntity : class + where TDependentEntity : class + { + ArgumentNullException.ThrowIfNull(ownedNavigationBuilder); + ArgumentNullException.ThrowIfNull(propertyExpression); + + return ConfigureSpatialIndex(ownedNavigationBuilder.HasIndex(propertyExpression), configure); + } + + /// + /// Configures a named SQL Server spatial index for a property of an owned entity. + /// + /// The owner entity type. + /// The owned entity type. + /// The owned-navigation builder. + /// An expression selecting the spatial property. + /// The EF Core model index name. + /// An optional action that configures spatial index options. + /// The index builder so that HasDatabaseName() can be chained. + /// + /// Map the property explicitly to geography or geometry. The mapped table must have a primary key + /// backed by a clustered SQL Server index. Other index options are not supported for spatial indexes. + /// + public static IndexBuilder HasSpatialIndex( + this OwnedNavigationBuilder ownedNavigationBuilder, + Expression> propertyExpression, + string modelIndexName, + Action? configure = null) + where TOwnerEntity : class + where TDependentEntity : class + { + ArgumentNullException.ThrowIfNull(ownedNavigationBuilder); + ArgumentNullException.ThrowIfNull(propertyExpression); + ArgumentException.ThrowIfNullOrWhiteSpace(modelIndexName); + + return ConfigureSpatialIndex( + ownedNavigationBuilder.HasIndex(propertyExpression, modelIndexName), + configure); + } + + /// + /// Configures a SQL Server spatial index for a named property of an owned entity. + /// + /// The owned-navigation builder. + /// The spatial property name. + /// An optional action that configures spatial index options. + /// The index builder so that HasDatabaseName() can be chained. + /// + /// Map the property explicitly to geography or geometry. The mapped table must have a primary key + /// backed by a clustered SQL Server index. Other index options are not supported for spatial indexes. + /// + public static IndexBuilder HasSpatialIndex( + this OwnedNavigationBuilder ownedNavigationBuilder, + string propertyName, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(ownedNavigationBuilder); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + + return ConfigureSpatialIndex(ownedNavigationBuilder.HasIndex(propertyName), configure); + } + + /// + /// Configures a named SQL Server spatial index for a named property of an owned entity. + /// + /// The owned-navigation builder. + /// The spatial property name. + /// The EF Core model index name. + /// An optional action that configures spatial index options. + /// The index builder so that HasDatabaseName() can be chained. + /// + /// Map the property explicitly to geography or geometry. The mapped table must have a primary key + /// backed by a clustered SQL Server index. Other index options are not supported for spatial indexes. + /// + public static IndexBuilder HasSpatialIndex( + this OwnedNavigationBuilder ownedNavigationBuilder, + string propertyName, + string modelIndexName, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(ownedNavigationBuilder); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + ArgumentException.ThrowIfNullOrWhiteSpace(modelIndexName); + + return ConfigureSpatialIndex(ownedNavigationBuilder.HasIndex([propertyName], modelIndexName), configure); + } + + private static TIndexBuilder ConfigureSpatialIndex( + TIndexBuilder indexBuilder, + Action? configure) + where TIndexBuilder : IndexBuilder + { + indexBuilder.HasAnnotation(AnnotationConstants.SpatialIndex, true); + configure?.Invoke(new SpatialIndexOptionsBuilder(indexBuilder.Metadata)); + + return indexBuilder; + } +} diff --git a/EntityFrameworkCore.Extensions/Features/SpatialIndexes/ExtendedSqlServerAnnotationProvider.SpatialIndexes.cs b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/ExtendedSqlServerAnnotationProvider.SpatialIndexes.cs new file mode 100644 index 0000000..6fb9da5 --- /dev/null +++ b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/ExtendedSqlServerAnnotationProvider.SpatialIndexes.cs @@ -0,0 +1,159 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace EntityFrameworkCore.Extensions.Services; + +#pragma warning disable EF1001 // Extending SQL Server's annotation provider requires its provider-internal implementation. + +internal sealed partial class ExtendedSqlServerAnnotationProvider +{ + /// + public override IEnumerable For(ITableIndex index, bool designTime) + { + foreach (var annotation in base.For(index, designTime)) + { + yield return annotation; + } + + if (!designTime) + { + yield break; + } + + var indexName = FormatIndexName(index.Table.Schema, index.Table.Name, index.Name); + var mappedIndexes = index.MappedIndexes.ToList(); + var spatialIndexes = mappedIndexes + .Select(mappedIndex => new + { + Index = mappedIndex, + Options = SpatialIndexAnnotation.GetOptions(mappedIndex, indexName), + }) + .Where(item => item.Options is not null) + .ToList(); + + if (spatialIndexes.Count == 0) + { + yield break; + } + + if (spatialIndexes.Count != mappedIndexes.Count) + { + throw new InvalidOperationException( + $"Relational index '{indexName}' combines spatial and ordinary model indexes."); + } + + var options = spatialIndexes[0].Options!; + if (spatialIndexes.Any(item => item.Options != options)) + { + throw new InvalidOperationException( + $"Relational index '{indexName}' has conflicting spatial index options."); + } + + ValidateSpatialIndex(index, spatialIndexes.Select(item => item.Index), indexName); + + var spatialType = index.Columns[0].StoreType.Trim().ToLowerInvariant(); + if (spatialType is not (SpatialIndexAnnotation.Geography or SpatialIndexAnnotation.Geometry)) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' targets column '{index.Columns[0].Name}' with store type " + + $"'{index.Columns[0].StoreType}'. Only SQL Server geography and geometry columns are supported."); + } + + SpatialIndexAnnotation.ValidateOptionsForType(options, spatialType, indexName); + + yield return new Annotation(AnnotationConstants.SpatialIndex, true); + yield return new Annotation(AnnotationConstants.SpatialIndexType, spatialType); + if (options.BoundingBox is { } boundingBox) + { + yield return new Annotation(AnnotationConstants.SpatialIndexBoundingBoxXMin, boundingBox.XMin); + yield return new Annotation(AnnotationConstants.SpatialIndexBoundingBoxYMin, boundingBox.YMin); + yield return new Annotation(AnnotationConstants.SpatialIndexBoundingBoxXMax, boundingBox.XMax); + yield return new Annotation(AnnotationConstants.SpatialIndexBoundingBoxYMax, boundingBox.YMax); + } + + if (options.CellsPerObject is { } cellsPerObject) + { + yield return new Annotation(AnnotationConstants.SpatialIndexCellsPerObject, cellsPerObject); + } + } + + private static void ValidateSpatialIndex( + ITableIndex index, + IEnumerable mappedIndexes, + string indexName) + { + if (index.Columns.Count != 1) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' must target exactly one column."); + } + + if (index.IsUnique) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot be unique."); + } + + if (index.Filter is not null) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot have a filter."); + } + + if (index.IsDescending is not null) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot specify sort order."); + } + + foreach (var mappedIndex in mappedIndexes) + { + if (mappedIndex.IsClustered() == true) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot be clustered."); + } + + if (mappedIndex.GetIncludeProperties() is { Count: > 0 }) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot have included columns."); + } + + if (mappedIndex.IsCreatedOnline() is not null) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' does not support ONLINE."); + } + + if (mappedIndex.GetFillFactor() is not null + || mappedIndex.GetSortInTempDb() is not null + || mappedIndex.GetDataCompression() is not null) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' does not support additional SQL Server index options yet."); + } + } + + var primaryKey = index.Table.PrimaryKey; + if (primaryKey is null) + { + throw new InvalidOperationException( + $"Table '{index.Table.SchemaQualifiedName}' must have a primary key before spatial index " + + $"'{index.Name}' can be created."); + } + + if (primaryKey.MappedKeys.Any(key => key.IsClustered() == false)) + { + throw new InvalidOperationException( + $"Table '{index.Table.SchemaQualifiedName}' must have a clustered primary key before spatial index " + + $"'{index.Name}' can be created."); + } + } + + private static string FormatIndexName(string? schema, string table, string index) + => schema is null ? $"{table}.{index}" : $"{schema}.{table}.{index}"; +} + +#pragma warning restore EF1001 diff --git a/EntityFrameworkCore.Extensions/Features/SpatialIndexes/ExtendedSqlServerMigrationsSqlGenerator.SpatialIndexes.cs b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/ExtendedSqlServerMigrationsSqlGenerator.SpatialIndexes.cs new file mode 100644 index 0000000..45f9927 --- /dev/null +++ b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/ExtendedSqlServerMigrationsSqlGenerator.SpatialIndexes.cs @@ -0,0 +1,141 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace EntityFrameworkCore.Extensions.Services; + +internal sealed partial class ExtendedSqlServerMigrationsSqlGenerator +{ + private static readonly string[] UnsupportedSpatialIndexAnnotations = + [ + "SqlServer:FillFactor", + "SqlServer:SortInTempDb", + "SqlServer:DataCompression", + ]; + + /// + protected override void Generate( + CreateIndexOperation operation, + IModel? model, + MigrationCommandListBuilder builder, + bool terminate) + { + var indexName = operation.Schema is null + ? $"{operation.Table}.{operation.Name}" + : $"{operation.Schema}.{operation.Table}.{operation.Name}"; + var configuration = SpatialIndexAnnotation.GetConfiguration(operation, indexName); + if (configuration is null) + { + base.Generate(operation, model, builder, terminate); + return; + } + + ValidateSpatialIndexOperation(operation, indexName); + + var sqlHelper = Dependencies.SqlGenerationHelper; + builder.Append("CREATE SPATIAL INDEX ") + .Append(sqlHelper.DelimitIdentifier(operation.Name)) + .Append(" ON ") + .Append(sqlHelper.DelimitIdentifier(operation.Table, operation.Schema)) + .Append(" (") + .Append(sqlHelper.DelimitIdentifier(operation.Columns[0])) + .AppendLine(")") + .Append("USING ") + .Append(configuration.SpatialType == SpatialIndexAnnotation.Geography + ? "GEOGRAPHY_AUTO_GRID" + : "GEOMETRY_AUTO_GRID"); + + if (configuration.BoundingBox is not null || configuration.CellsPerObject is not null) + { + builder.AppendLine() + .Append("WITH ("); + + if (configuration.BoundingBox is { } boundingBox) + { + builder.Append("BOUNDING_BOX = (") + .Append(FormatCoordinate(boundingBox.XMin)) + .Append(", ") + .Append(FormatCoordinate(boundingBox.YMin)) + .Append(", ") + .Append(FormatCoordinate(boundingBox.XMax)) + .Append(", ") + .Append(FormatCoordinate(boundingBox.YMax)) + .Append(")"); + } + + if (configuration.CellsPerObject is { } cellsPerObject) + { + if (configuration.BoundingBox is not null) + { + builder.Append(", "); + } + + builder.Append("CELLS_PER_OBJECT = ") + .Append(cellsPerObject.ToString(CultureInfo.InvariantCulture)); + } + + builder.Append(")"); + } + + if (terminate) + { + builder.Append(sqlHelper.StatementTerminator) + .EndCommand(); + } + } + + private static void ValidateSpatialIndexOperation(CreateIndexOperation operation, string indexName) + { + if (operation.Columns.Length != 1) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' must target exactly one column."); + } + + if (operation.IsUnique) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot be unique."); + } + + if (operation.Filter is not null) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot have a filter."); + } + + if (operation.IsDescending is not null) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot specify sort order."); + } + + if (operation["SqlServer:Clustered"] is true) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot be clustered."); + } + + if (operation["SqlServer:Include"] is Array { Length: > 0 }) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' cannot have included columns."); + } + + if (operation.FindAnnotation("SqlServer:Online") is not null) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' does not support ONLINE."); + } + + if (UnsupportedSpatialIndexAnnotations.Any(name => operation.FindAnnotation(name) is not null)) + { + throw new InvalidOperationException( + $"Spatial index '{indexName}' does not support additional SQL Server index options yet."); + } + } + + private static string FormatCoordinate(double coordinate) + => coordinate.ToString("R", CultureInfo.InvariantCulture); +} diff --git a/EntityFrameworkCore.Extensions/Features/SpatialIndexes/SpatialIndexAnnotation.cs b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/SpatialIndexAnnotation.cs new file mode 100644 index 0000000..7edab62 --- /dev/null +++ b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/SpatialIndexAnnotation.cs @@ -0,0 +1,165 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace EntityFrameworkCore.Extensions.Services; + +internal static class SpatialIndexAnnotation +{ + public const string Geography = "geography"; + public const string Geometry = "geometry"; + + public static SpatialIndexOptions? GetOptions(IReadOnlyAnnotatable annotatable, string objectName) + { + var marker = annotatable.FindAnnotation(AnnotationConstants.SpatialIndex); + if (marker is null) + { + return null; + } + + if (marker.Value is not bool isSpatialIndex) + { + throw InvalidAnnotationValue(marker, objectName, "a Boolean"); + } + + if (!isSpatialIndex) + { + return null; + } + + var xMin = GetOptionalValue( + annotatable, + AnnotationConstants.SpatialIndexBoundingBoxXMin, + objectName); + var yMin = GetOptionalValue( + annotatable, + AnnotationConstants.SpatialIndexBoundingBoxYMin, + objectName); + var xMax = GetOptionalValue( + annotatable, + AnnotationConstants.SpatialIndexBoundingBoxXMax, + objectName); + var yMax = GetOptionalValue( + annotatable, + AnnotationConstants.SpatialIndexBoundingBoxYMax, + objectName); + + var specifiedCoordinates = new double?[] { xMin, yMin, xMax, yMax }.Count(value => value.HasValue); + if (specifiedCoordinates is > 0 and < 4) + { + throw new InvalidOperationException( + $"Spatial index '{objectName}' must specify all four bounding-box coordinates."); + } + + SpatialBoundingBox? boundingBox = null; + if (specifiedCoordinates == 4) + { + if (!double.IsFinite(xMin!.Value) + || !double.IsFinite(yMin!.Value) + || !double.IsFinite(xMax!.Value) + || !double.IsFinite(yMax!.Value) + || xMin.Value >= xMax.Value + || yMin.Value >= yMax.Value) + { + throw new InvalidOperationException( + $"Spatial index '{objectName}' has an invalid bounding box. Coordinates must be finite, " + + "and each minimum must be less than its maximum."); + } + + boundingBox = new SpatialBoundingBox(xMin.Value, yMin.Value, xMax.Value, yMax.Value); + } + + var cellsPerObject = GetOptionalValue( + annotatable, + AnnotationConstants.SpatialIndexCellsPerObject, + objectName); + if (cellsPerObject is < 1 or > 8192) + { + throw new InvalidOperationException( + $"Spatial index '{objectName}' has an invalid cells-per-object value. " + + "The value must be between 1 and 8192."); + } + + return new SpatialIndexOptions(boundingBox, cellsPerObject); + } + + public static SpatialIndexConfiguration? GetConfiguration( + IReadOnlyAnnotatable annotatable, + string objectName) + { + var options = GetOptions(annotatable, objectName); + if (options is null) + { + return null; + } + + var spatialTypeAnnotation = annotatable.FindAnnotation(AnnotationConstants.SpatialIndexType) + ?? throw new InvalidOperationException( + $"Spatial index '{objectName}' does not identify its SQL Server spatial type."); + if (spatialTypeAnnotation.Value is not string spatialType + || (spatialType != Geography && spatialType != Geometry)) + { + throw InvalidAnnotationValue( + spatialTypeAnnotation, + objectName, + $"either '{Geography}' or '{Geometry}'"); + } + + ValidateOptionsForType(options, spatialType, objectName); + return new SpatialIndexConfiguration(spatialType, options.BoundingBox, options.CellsPerObject); + } + + public static void ValidateOptionsForType( + SpatialIndexOptions options, + string spatialType, + string objectName) + { + if (spatialType == Geometry && options.BoundingBox is null) + { + throw new InvalidOperationException( + $"Geometry spatial index '{objectName}' requires a bounding box. " + + $"Configure it with {nameof(SpatialIndexOptionsBuilder)}.{nameof(SpatialIndexOptionsBuilder.HasBoundingBox)}()."); + } + + if (spatialType == Geography && options.BoundingBox is not null) + { + throw new InvalidOperationException( + $"Geography spatial index '{objectName}' cannot have a bounding box."); + } + } + + private static T? GetOptionalValue( + IReadOnlyAnnotatable annotatable, + string annotationName, + string objectName) + where T : struct + { + var annotation = annotatable.FindAnnotation(annotationName); + if (annotation is null) + { + return null; + } + + if (annotation.Value is not T value) + { + throw InvalidAnnotationValue(annotation, objectName, $"a {typeof(T).Name}"); + } + + return value; + } + + private static InvalidOperationException InvalidAnnotationValue( + IAnnotation annotation, + string objectName, + string expectedValue) + => new( + $"The '{annotation.Name}' annotation on spatial index '{objectName}' must contain {expectedValue}; " + + $"found '{annotation.Value?.GetType().Name ?? "null"}'."); +} + +internal sealed record SpatialIndexOptions(SpatialBoundingBox? BoundingBox, int? CellsPerObject); + +internal sealed record SpatialIndexConfiguration( + string SpatialType, + SpatialBoundingBox? BoundingBox, + int? CellsPerObject); + +internal sealed record SpatialBoundingBox(double XMin, double YMin, double XMax, double YMax); diff --git a/EntityFrameworkCore.Extensions/Features/SpatialIndexes/SpatialIndexOptionsBuilder.cs b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/SpatialIndexOptionsBuilder.cs new file mode 100644 index 0000000..a08d3c9 --- /dev/null +++ b/EntityFrameworkCore.Extensions/Features/SpatialIndexes/SpatialIndexOptionsBuilder.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore.Metadata; + +namespace EntityFrameworkCore.Extensions; + +/// +/// Configures SQL Server spatial index options. +/// +public sealed class SpatialIndexOptionsBuilder +{ + private readonly IMutableIndex _index; + + internal SpatialIndexOptionsBuilder(IMutableIndex index) + { + _index = index; + } + + /// + /// Configures the geometry coordinate space covered by the spatial index. + /// + /// The minimum X coordinate. + /// The minimum Y coordinate. + /// The maximum X coordinate. + /// The maximum Y coordinate. + /// The same options builder. + /// A bounding box is required for geometry and is not valid for geography. + public SpatialIndexOptionsBuilder HasBoundingBox(double xMin, double yMin, double xMax, double yMax) + { + ThrowIfNotFinite(xMin, nameof(xMin)); + ThrowIfNotFinite(yMin, nameof(yMin)); + ThrowIfNotFinite(xMax, nameof(xMax)); + ThrowIfNotFinite(yMax, nameof(yMax)); + + if (xMin >= xMax) + { + throw new ArgumentOutOfRangeException( + nameof(xMin), + "The minimum X coordinate must be less than the maximum X coordinate."); + } + + if (yMin >= yMax) + { + throw new ArgumentOutOfRangeException( + nameof(yMin), + "The minimum Y coordinate must be less than the maximum Y coordinate."); + } + + _index.SetAnnotation(AnnotationConstants.SpatialIndexBoundingBoxXMin, xMin); + _index.SetAnnotation(AnnotationConstants.SpatialIndexBoundingBoxYMin, yMin); + _index.SetAnnotation(AnnotationConstants.SpatialIndexBoundingBoxXMax, xMax); + _index.SetAnnotation(AnnotationConstants.SpatialIndexBoundingBoxYMax, yMax); + + return this; + } + + /// + /// Configures the maximum number of tessellation cells used for one spatial object. + /// + /// A value from 1 through 8192. + /// The same options builder. + public SpatialIndexOptionsBuilder HasCellsPerObject(int cellsPerObject) + { + if (cellsPerObject is < 1 or > 8192) + { + throw new ArgumentOutOfRangeException( + nameof(cellsPerObject), + cellsPerObject, + "Cells per object must be between 1 and 8192."); + } + + _index.SetAnnotation(AnnotationConstants.SpatialIndexCellsPerObject, cellsPerObject); + + return this; + } + + private static void ThrowIfNotFinite(double coordinate, string parameterName) + { + if (!double.IsFinite(coordinate)) + { + throw new ArgumentOutOfRangeException( + parameterName, + coordinate, + "Spatial index bounding-box coordinates must be finite numbers."); + } + } +} diff --git a/EntityFrameworkCore.Extensions/Shared/AnnotationConstants.cs b/EntityFrameworkCore.Extensions/Shared/AnnotationConstants.cs new file mode 100644 index 0000000..9f692e2 --- /dev/null +++ b/EntityFrameworkCore.Extensions/Shared/AnnotationConstants.cs @@ -0,0 +1,8 @@ +namespace EntityFrameworkCore.Extensions; + +/// +/// Contains annotation names used by EntityFrameworkCore.Extensions. +/// +public static partial class AnnotationConstants +{ +} diff --git a/EntityFrameworkCore.Extensions/DatabaseFacadeExtensions.cs b/EntityFrameworkCore.Extensions/Shared/DatabaseFacadeExtensions.cs similarity index 100% rename from EntityFrameworkCore.Extensions/DatabaseFacadeExtensions.cs rename to EntityFrameworkCore.Extensions/Shared/DatabaseFacadeExtensions.cs diff --git a/EntityFrameworkCore.Extensions/DbContextOptionsBuilderExtensions.cs b/EntityFrameworkCore.Extensions/Shared/DbContextOptionsBuilderExtensions.cs similarity index 100% rename from EntityFrameworkCore.Extensions/DbContextOptionsBuilderExtensions.cs rename to EntityFrameworkCore.Extensions/Shared/DbContextOptionsBuilderExtensions.cs diff --git a/EntityFrameworkCore.Extensions/MigrationBuilderExtensions.cs b/EntityFrameworkCore.Extensions/Shared/MigrationBuilderExtensions.cs similarity index 100% rename from EntityFrameworkCore.Extensions/MigrationBuilderExtensions.cs rename to EntityFrameworkCore.Extensions/Shared/MigrationBuilderExtensions.cs diff --git a/EntityFrameworkCore.Extensions/ModelBuilderExtensions.cs b/EntityFrameworkCore.Extensions/Shared/ModelBuilderExtensions.cs similarity index 100% rename from EntityFrameworkCore.Extensions/ModelBuilderExtensions.cs rename to EntityFrameworkCore.Extensions/Shared/ModelBuilderExtensions.cs diff --git a/EntityFrameworkCore.Extensions/Services/EntityFrameworkCoreExtensionsOptionsExtension.cs b/EntityFrameworkCore.Extensions/Shared/Services/EntityFrameworkCoreExtensionsOptionsExtension.cs similarity index 100% rename from EntityFrameworkCore.Extensions/Services/EntityFrameworkCoreExtensionsOptionsExtension.cs rename to EntityFrameworkCore.Extensions/Shared/Services/EntityFrameworkCoreExtensionsOptionsExtension.cs diff --git a/EntityFrameworkCore.Extensions/Shared/Services/ExtendedSqlServerAnnotationProvider.cs b/EntityFrameworkCore.Extensions/Shared/Services/ExtendedSqlServerAnnotationProvider.cs new file mode 100644 index 0000000..57a52f3 --- /dev/null +++ b/EntityFrameworkCore.Extensions/Shared/Services/ExtendedSqlServerAnnotationProvider.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; + +namespace EntityFrameworkCore.Extensions.Services; + +#pragma warning disable EF1001 // Extending SQL Server's annotation provider requires its provider-internal implementation. + +/// +/// Propagates EntityFrameworkCore.Extensions annotations to the SQL Server relational model. +/// +internal sealed partial class ExtendedSqlServerAnnotationProvider : SqlServerAnnotationProvider +{ + /// Initializes a new annotation provider instance. + /// The relational annotation provider dependencies. + public ExtendedSqlServerAnnotationProvider(RelationalAnnotationProviderDependencies dependencies) : base(dependencies) + { + } +} + +#pragma warning restore EF1001 diff --git a/EntityFrameworkCore.Extensions/Shared/Services/ExtendedSqlServerMigrationsSqlGenerator.cs b/EntityFrameworkCore.Extensions/Shared/Services/ExtendedSqlServerMigrationsSqlGenerator.cs new file mode 100644 index 0000000..69d32e3 --- /dev/null +++ b/EntityFrameworkCore.Extensions/Shared/Services/ExtendedSqlServerMigrationsSqlGenerator.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Update; + +namespace EntityFrameworkCore.Extensions.Services; + +/// +/// Generates SQL Server migration commands for EntityFrameworkCore.Extensions annotations. +/// +internal sealed partial class ExtendedSqlServerMigrationsSqlGenerator : SqlServerMigrationsSqlGenerator +{ + /// Initializes a new generator instance. + /// The relational migration SQL dependencies. + /// The SQL Server modification-command batch preparer. + public ExtendedSqlServerMigrationsSqlGenerator( + MigrationsSqlGeneratorDependencies dependencies, + ICommandBatchPreparer commandBatchPreparer) + : base(dependencies, commandBatchPreparer) + { + } +} diff --git a/README.md b/README.md index 3cbb288..34f3cf7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/nikitasavinov/EntityFrameworkCore.Extensions/actions/workflows/dotnetcore.yml/badge.svg)](https://github.com/nikitasavinov/EntityFrameworkCore.Extensions/actions/workflows/dotnetcore.yml) [![NuGet downloads](https://img.shields.io/nuget/dt/EntityFrameworkCore.Extensions?logo=nuget&label=downloads&color=004880)](https://www.nuget.org/packages/EntityFrameworkCore.Extensions/) -SQL Server dynamic data masking and migration helpers for EF Core 10. +SQL Server spatial indexes, dynamic data masking, and migration helpers for EF Core 10. See [EntityFrameworkCore.Extensions.Samples](./EntityFrameworkCore.Extensions.Samples) for more usage examples. @@ -87,3 +87,40 @@ public static class Program } } ``` + +## Spatial indexes + +Install `Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite`, enable it in `UseSqlServer()`, and configure the index alongside the rest of the entity model: + +```csharp +optionsBuilder.UseSqlServer( + connectionString, + sqlServer => sqlServer.UseNetTopologySuite()); +optionsBuilder.UseEntityFrameworkCoreExtensions(); + +modelBuilder.Entity() + .Property(place => place.Location) + .HasColumnType("geography"); + +modelBuilder.Entity() + .HasSpatialIndex(place => place.Location) + .HasDatabaseName("SIX_Places_Location"); + +modelBuilder.Entity() + .Property(region => region.Boundary) + .HasColumnType("geometry"); + +modelBuilder.Entity() + .HasSpatialIndex( + region => region.Boundary, + spatial => spatial + .HasBoundingBox(-180, -90, 180, 90) + .HasCellsPerObject(32)) + .HasDatabaseName("SIX_Regions_Boundary"); +``` + +`geography` uses `GEOGRAPHY_AUTO_GRID`. `geometry` uses `GEOMETRY_AUTO_GRID` and requires a bounding box. + +Each entity with a spatial index must have a primary key backed by a clustered SQL Server index. This is the SQL Server provider default; configuring the primary key with `.IsClustered(false)` is not supported. Only `.HasDatabaseName()` may be chained after `.HasSpatialIndex()`; unique, filtered, clustered, included-column, descending, and other SQL Server index options are not supported. + +Use the overload with a model index name to create multiple spatial indexes on one property, and use `.HasDatabaseName()` to give each one a distinct SQL index name. The same expression-based, string-based, and named overloads are available on `OwnedNavigationBuilder`. From 9b9014c97d9bde759e65b9693c14f0c6e5e8a064 Mon Sep 17 00:00:00 2001 From: nikitasavinov <6826684+nikitasavinov@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:08:42 +0200 Subject: [PATCH 2/2] release prep --- .../EntityFrameworkCore.Extensions.csproj | 4 ++-- README.md | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj b/EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj index c3b2878..74a63d0 100644 --- a/EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj +++ b/EntityFrameworkCore.Extensions/EntityFrameworkCore.Extensions.csproj @@ -3,7 +3,7 @@ net10.0 true EntityFrameworkCore.Extensions - 10.0.0 + 10.1.0 Nikita Savinov Nikita Savinov SQL Server spatial indexes, dynamic data masking, and migration helpers for EF Core 10. @@ -13,7 +13,7 @@ MIT false README.md - EntityFrameworkCore;EntityFramework;entity-framework-core;EFCore;SQLServer;spatial;spatial-index;geography;geometry;dynamic-data-masking;data-masking;fluent-api;migrations;sql-migrations + EntityFrameworkCore;EFCore;SQLServer;spatial-index;dynamic-data-masking;data-masking;migrations true true snupkg diff --git a/README.md b/README.md index 34f3cf7..a2ec0d2 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ See [EntityFrameworkCore.Extensions.Samples](./EntityFrameworkCore.Extensions.Sa ## Features - SQL Server dynamic data masking with migration support. +- SQL Server `geography` and `geometry` spatial indexes. - Model-wide delete behavior. - SQL files in migrations. - Provider-aware synchronous and asynchronous migrations. -- [Upcoming] SQL Server `geography` and `geometry` spatial indexes. - [Upcoming] Dynamic data masking polish: scoped `GRANT` / `REVOKE UNMASK` support and remaining alter/drop edge cases. - [Upcoming] Row-level security through fluent annotations and migration SQL. - [Upcoming] SQL Server ledger table support. @@ -21,6 +21,11 @@ See [EntityFrameworkCore.Extensions.Samples](./EntityFrameworkCore.Extensions.Sa ## Changelog +### 10.1.0 + +- Added SQL Server auto-grid spatial indexes for `geography` and `geometry`, including bounding-box and cells-per-object options. +- Added fluent configuration for entity and owned-entity spatial indexes, with migration support. + ### 10.0.0 EntityFrameworkCore.Extensions has been revived and modernized after several years: