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