diff --git a/Benchmarks.App/BenchmarkRunner.cs b/Benchmarks.App/BenchmarkRunner.cs index f3b0a38..8518a04 100644 --- a/Benchmarks.App/BenchmarkRunner.cs +++ b/Benchmarks.App/BenchmarkRunner.cs @@ -1,5 +1,7 @@ namespace Benchmarks.App; +using System.Collections.ObjectModel; + internal static class BenchmarkRunner { public static IEnumerable RunAndBuildSummaries() @@ -19,7 +21,8 @@ public static IEnumerable RunAndBuildSummaries(BenchmarkSettings settin return RunAndBuildSummaries([type], args); } - private static IEnumerable RunAndBuildSummaries(Type[] benchmarkTypes, string[] args) + // ReSharper disable once ReturnTypeCanBeEnumerable.Local - Performance + private static List RunAndBuildSummaries(Type[] benchmarkTypes, string[] args) { AnsiConsole.Cursor.Move(CursorDirection.Up, 1); @@ -34,8 +37,6 @@ private static IEnumerable RunAndBuildSummaries(Type[] benchmarkTypes, Console.SetOut(Console.Out); - AnsiConsole.Cursor.Move(CursorDirection.Down, 1); - return summaries; } diff --git a/Benchmarks.Core/Database/BenchmarkDbContext.cs b/Benchmarks.Core/Database/BenchmarkDbContext.cs index e6cb4e7..d63a8bb 100644 --- a/Benchmarks.Core/Database/BenchmarkDbContext.cs +++ b/Benchmarks.Core/Database/BenchmarkDbContext.cs @@ -2,30 +2,38 @@ public class BenchmarkDbContext(DbContextOptions options) : DbContext(options) { - public DbSet ClusteredIndexes { get; set; } = null!; + // GuidPrimaryKey Benchmarks + public DbSet ClusteredIndexes { get; set; } = null!; public DbSet GuidPrimaryKeys { get; set; } = null!; - public DbSet HardDeletes { get; set; } = null!; - public DbSet NonClusteredIndexes { get; set; } = null!; - public DbSet SoftDeletes { get; set; } = null!; + public DbSet NonClusteredIndexes { get; set; } = null!; + // SoftDelete Benchmarks + public DbSet HardDeletes { get; set; } = null!; + public DbSet SoftDeleteWithFilters { get; set; } = null!; + public DbSet SoftDeleteWithoutFilters { get; set; } = null!; protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity() - .HasKey(p => p.Id) + // GuidPrimaryKey Benchmarks + modelBuilder.Entity() + .HasKey(e => e.Id) .IsClustered(); - modelBuilder.Entity() - .HasKey(p => p.Id); - - modelBuilder.Entity() - .HasKey(p => p.Id); - - modelBuilder.Entity() - .HasKey(p => p.Id) + .HasKey(e => e.Id); + modelBuilder.Entity() + .HasKey(e => e.Id) .IsClustered(false); - modelBuilder.Entity() + // SoftDelete Benchmarks + modelBuilder.Entity() + .HasKey(e => e.Id); + modelBuilder.Entity() + .HasQueryFilter(e => !e.IsDeleted) + .HasKey(p => p.Id); + modelBuilder.Entity() + .HasIndex(e => e.IsDeleted) + .HasFilter("IsDeleted = 0"); + modelBuilder.Entity() + .HasQueryFilter(e => !e.IsDeleted) .HasKey(p => p.Id); - } } diff --git a/Benchmarks.Core/Database/Postgres/Migrations/20240317091734_Postgres.Designer.cs b/Benchmarks.Core/Database/Postgres/Migrations/20240317105114_Postgres.Designer.cs similarity index 76% rename from Benchmarks.Core/Database/Postgres/Migrations/20240317091734_Postgres.Designer.cs rename to Benchmarks.Core/Database/Postgres/Migrations/20240317105114_Postgres.Designer.cs index 90eecb9..d709eef 100644 --- a/Benchmarks.Core/Database/Postgres/Migrations/20240317091734_Postgres.Designer.cs +++ b/Benchmarks.Core/Database/Postgres/Migrations/20240317105114_Postgres.Designer.cs @@ -12,7 +12,7 @@ namespace Benchmarks.Core.Database.Postgres.Migrations { [DbContext(typeof(PostgresDbContext))] - [Migration("20240317091734_Postgres")] + [Migration("20240317105114_Postgres")] partial class Postgres { /// @@ -25,7 +25,28 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Benchmarks.Core.Entities.ClusteredIndex", b => + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LongInteger") + .HasColumnType("bigint"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("GuidPrimaryKeys"); + }); + + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKeyWithClusteredIndex", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -47,7 +68,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("ClusteredIndexes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKey", b => + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKeyWithNonClusteredIndex", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -63,12 +84,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.HasKey("Id"); + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", false); - b.ToTable("GuidPrimaryKeys"); + b.ToTable("NonClusteredIndexes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.HardDeleted", b => + modelBuilder.Entity("Benchmarks.Core.Entities.HardDelete", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -91,15 +113,23 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("HardDeletes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.NonClusteredIndex", b => + modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleteWithFilter", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("LongInteger") .HasColumnType("bigint"); @@ -107,13 +137,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.HasKey("Id") - .HasAnnotation("SqlServer:Clustered", false); + b.HasKey("Id"); - b.ToTable("NonClusteredIndexes"); + b.HasIndex("IsDeleted") + .HasFilter("IsDeleted = 0"); + + b.ToTable("SoftDeleteWithFilters"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleted", b => + modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleteWithoutFilter", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -139,7 +171,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("SoftDeletes"); + b.ToTable("SoftDeleteWithoutFilters"); }); #pragma warning restore 612, 618 } diff --git a/Benchmarks.Core/Database/Postgres/Migrations/20240317091734_Postgres.cs b/Benchmarks.Core/Database/Postgres/Migrations/20240317105114_Postgres.cs similarity index 75% rename from Benchmarks.Core/Database/Postgres/Migrations/20240317091734_Postgres.cs rename to Benchmarks.Core/Database/Postgres/Migrations/20240317105114_Postgres.cs index 8ea8f90..d4fed0f 100644 --- a/Benchmarks.Core/Database/Postgres/Migrations/20240317091734_Postgres.cs +++ b/Benchmarks.Core/Database/Postgres/Migrations/20240317105114_Postgres.cs @@ -70,7 +70,7 @@ protected override void Up(MigrationBuilder migrationBuilder) }); migrationBuilder.CreateTable( - name: "SoftDeletes", + name: "SoftDeleteWithFilters", columns: table => new { Id = table.Column(type: "bigint", nullable: false) @@ -83,8 +83,31 @@ protected override void Up(MigrationBuilder migrationBuilder) }, constraints: table => { - table.PrimaryKey("PK_SoftDeletes", x => x.Id); + table.PrimaryKey("PK_SoftDeleteWithFilters", x => x.Id); }); + + migrationBuilder.CreateTable( + name: "SoftDeleteWithoutFilters", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + IsDeleted = table.Column(type: "boolean", nullable: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + Text = table.Column(type: "text", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + LongInteger = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SoftDeleteWithoutFilters", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_SoftDeleteWithFilters_IsDeleted", + table: "SoftDeleteWithFilters", + column: "IsDeleted", + filter: "IsDeleted = 0"); } /// @@ -103,7 +126,10 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "NonClusteredIndexes"); migrationBuilder.DropTable( - name: "SoftDeletes"); + name: "SoftDeleteWithFilters"); + + migrationBuilder.DropTable( + name: "SoftDeleteWithoutFilters"); } } } diff --git a/Benchmarks.Core/Database/Postgres/Migrations/PostgresDbContextModelSnapshot.cs b/Benchmarks.Core/Database/Postgres/Migrations/PostgresDbContextModelSnapshot.cs index 5866048..b8f77a7 100644 --- a/Benchmarks.Core/Database/Postgres/Migrations/PostgresDbContextModelSnapshot.cs +++ b/Benchmarks.Core/Database/Postgres/Migrations/PostgresDbContextModelSnapshot.cs @@ -22,7 +22,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Benchmarks.Core.Entities.ClusteredIndex", b => + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LongInteger") + .HasColumnType("bigint"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("GuidPrimaryKeys"); + }); + + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKeyWithClusteredIndex", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -44,7 +65,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ClusteredIndexes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKey", b => + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKeyWithNonClusteredIndex", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -60,12 +81,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.HasKey("Id"); + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", false); - b.ToTable("GuidPrimaryKeys"); + b.ToTable("NonClusteredIndexes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.HardDeleted", b => + modelBuilder.Entity("Benchmarks.Core.Entities.HardDelete", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -88,15 +110,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("HardDeletes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.NonClusteredIndex", b => + modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleteWithFilter", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("LongInteger") .HasColumnType("bigint"); @@ -104,13 +134,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.HasKey("Id") - .HasAnnotation("SqlServer:Clustered", false); + b.HasKey("Id"); - b.ToTable("NonClusteredIndexes"); + b.HasIndex("IsDeleted") + .HasFilter("IsDeleted = 0"); + + b.ToTable("SoftDeleteWithFilters"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleted", b => + modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleteWithoutFilter", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -136,7 +168,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("SoftDeletes"); + b.ToTable("SoftDeleteWithoutFilters"); }); #pragma warning restore 612, 618 } diff --git a/Benchmarks.Core/Database/SoftDeleteInterceptor.cs b/Benchmarks.Core/Database/SoftDeleteInterceptor.cs new file mode 100644 index 0000000..4261855 --- /dev/null +++ b/Benchmarks.Core/Database/SoftDeleteInterceptor.cs @@ -0,0 +1,30 @@ +namespace Benchmarks.Core.Database; + +public sealed class SoftDeleteInterceptor : SaveChangesInterceptor +{ + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is null) + { + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + eventData + .Context + .ChangeTracker + .Entries() + .Where(e => e.State == EntityState.Deleted) + .ToList() + .ForEach(entity => + { + entity.State = EntityState.Modified; + entity.Entity.IsDeleted = true; + entity.Entity.DeletedAtUtc = DateTimeOffset.UtcNow; + }); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } +} diff --git a/Benchmarks.Core/Database/SqlServer/Migrations/20240317091735_SqlServer.Designer.cs b/Benchmarks.Core/Database/SqlServer/Migrations/20240317105116_SqlServer.Designer.cs similarity index 76% rename from Benchmarks.Core/Database/SqlServer/Migrations/20240317091735_SqlServer.Designer.cs rename to Benchmarks.Core/Database/SqlServer/Migrations/20240317105116_SqlServer.Designer.cs index e25335a..0065b20 100644 --- a/Benchmarks.Core/Database/SqlServer/Migrations/20240317091735_SqlServer.Designer.cs +++ b/Benchmarks.Core/Database/SqlServer/Migrations/20240317105116_SqlServer.Designer.cs @@ -12,7 +12,7 @@ namespace Benchmarks.Core.Database.SqlServer.Migrations { [DbContext(typeof(SqlServerDbContext))] - [Migration("20240317091735_SqlServer")] + [Migration("20240317105116_SqlServer")] partial class SqlServer { /// @@ -25,7 +25,28 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Benchmarks.Core.Entities.ClusteredIndex", b => + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LongInteger") + .HasColumnType("bigint"); + + b.Property("Text") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GuidPrimaryKeys"); + }); + + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKeyWithClusteredIndex", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -48,7 +69,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("ClusteredIndexes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKey", b => + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKeyWithNonClusteredIndex", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -66,10 +87,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("GuidPrimaryKeys"); + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.ToTable("NonClusteredIndexes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.HardDeleted", b => + modelBuilder.Entity("Benchmarks.Core.Entities.HardDelete", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -92,15 +115,23 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("HardDeletes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.NonClusteredIndex", b => + modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleteWithFilter", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); b.Property("CreatedAtUtc") .HasColumnType("datetimeoffset"); + b.Property("DeletedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + b.Property("LongInteger") .HasColumnType("bigint"); @@ -110,12 +141,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + b.HasIndex("IsDeleted") + .HasFilter("IsDeleted = 0"); - b.ToTable("NonClusteredIndexes"); + b.ToTable("SoftDeleteWithFilters"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleted", b => + modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleteWithoutFilter", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -141,7 +173,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("SoftDeletes"); + b.ToTable("SoftDeleteWithoutFilters"); }); #pragma warning restore 612, 618 } diff --git a/Benchmarks.Core/Database/SqlServer/Migrations/20240317091735_SqlServer.cs b/Benchmarks.Core/Database/SqlServer/Migrations/20240317105116_SqlServer.cs similarity index 76% rename from Benchmarks.Core/Database/SqlServer/Migrations/20240317091735_SqlServer.cs rename to Benchmarks.Core/Database/SqlServer/Migrations/20240317105116_SqlServer.cs index 7a7d395..1062ced 100644 --- a/Benchmarks.Core/Database/SqlServer/Migrations/20240317091735_SqlServer.cs +++ b/Benchmarks.Core/Database/SqlServer/Migrations/20240317105116_SqlServer.cs @@ -71,7 +71,7 @@ protected override void Up(MigrationBuilder migrationBuilder) }); migrationBuilder.CreateTable( - name: "SoftDeletes", + name: "SoftDeleteWithFilters", columns: table => new { Id = table.Column(type: "bigint", nullable: false) @@ -84,8 +84,31 @@ protected override void Up(MigrationBuilder migrationBuilder) }, constraints: table => { - table.PrimaryKey("PK_SoftDeletes", x => x.Id); + table.PrimaryKey("PK_SoftDeleteWithFilters", x => x.Id); }); + + migrationBuilder.CreateTable( + name: "SoftDeleteWithoutFilters", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + IsDeleted = table.Column(type: "bit", nullable: false), + DeletedAtUtc = table.Column(type: "datetimeoffset", nullable: true), + Text = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false), + LongInteger = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SoftDeleteWithoutFilters", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_SoftDeleteWithFilters_IsDeleted", + table: "SoftDeleteWithFilters", + column: "IsDeleted", + filter: "IsDeleted = 0"); } /// @@ -104,7 +127,10 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "NonClusteredIndexes"); migrationBuilder.DropTable( - name: "SoftDeletes"); + name: "SoftDeleteWithFilters"); + + migrationBuilder.DropTable( + name: "SoftDeleteWithoutFilters"); } } } diff --git a/Benchmarks.Core/Database/SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs b/Benchmarks.Core/Database/SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs index c0c7f94..3e006fd 100644 --- a/Benchmarks.Core/Database/SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs +++ b/Benchmarks.Core/Database/SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -22,7 +22,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Benchmarks.Core.Entities.ClusteredIndex", b => + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LongInteger") + .HasColumnType("bigint"); + + b.Property("Text") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("GuidPrimaryKeys"); + }); + + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKeyWithClusteredIndex", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -45,7 +66,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ClusteredIndexes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKey", b => + modelBuilder.Entity("Benchmarks.Core.Entities.GuidPrimaryKeyWithNonClusteredIndex", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -63,10 +84,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("GuidPrimaryKeys"); + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.ToTable("NonClusteredIndexes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.HardDeleted", b => + modelBuilder.Entity("Benchmarks.Core.Entities.HardDelete", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -89,15 +112,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("HardDeletes"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.NonClusteredIndex", b => + modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleteWithFilter", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); b.Property("CreatedAtUtc") .HasColumnType("datetimeoffset"); + b.Property("DeletedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + b.Property("LongInteger") .HasColumnType("bigint"); @@ -107,12 +138,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + b.HasIndex("IsDeleted") + .HasFilter("IsDeleted = 0"); - b.ToTable("NonClusteredIndexes"); + b.ToTable("SoftDeleteWithFilters"); }); - modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleted", b => + modelBuilder.Entity("Benchmarks.Core.Entities.SoftDeleteWithoutFilter", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -138,7 +170,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("SoftDeletes"); + b.ToTable("SoftDeleteWithoutFilters"); }); #pragma warning restore 612, 618 } diff --git a/Benchmarks.Core/Entities/GuidPrimaryKey.cs b/Benchmarks.Core/Entities/GuidPrimaryKey.cs index ed40983..9d5c8e4 100644 --- a/Benchmarks.Core/Entities/GuidPrimaryKey.cs +++ b/Benchmarks.Core/Entities/GuidPrimaryKey.cs @@ -10,6 +10,6 @@ public abstract class GuidPrimaryKeyBase : IBaseEntity public sealed class GuidPrimaryKey : GuidPrimaryKeyBase; -public sealed class ClusteredIndex : GuidPrimaryKeyBase; +public sealed class GuidPrimaryKeyWithClusteredIndex : GuidPrimaryKeyBase; -public sealed class NonClusteredIndex : GuidPrimaryKeyBase; +public sealed class GuidPrimaryKeyWithNonClusteredIndex : GuidPrimaryKeyBase; diff --git a/Benchmarks.Core/Entities/SoftDelete.cs b/Benchmarks.Core/Entities/SoftDelete.cs new file mode 100644 index 0000000..3088a75 --- /dev/null +++ b/Benchmarks.Core/Entities/SoftDelete.cs @@ -0,0 +1,30 @@ +namespace Benchmarks.Core.Entities; + +public interface ISoftDeletable +{ + bool IsDeleted { get; set; } + + DateTimeOffset? DeletedAtUtc { get; set; } +} + +public abstract class LongPrimaryKeyBase : IBaseEntity, ILongPrimaryKey +{ + public long Id { get; init; } + public string Text { get; init; } = string.Empty; + public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public long LongInteger { get; init; } +} + +public sealed class SoftDeleteWithFilter : LongPrimaryKeyBase, ISoftDeletable +{ + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAtUtc { get; set; } +} + +public sealed class SoftDeleteWithoutFilter : LongPrimaryKeyBase, ISoftDeletable +{ + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAtUtc { get; set; } +} + +public sealed class HardDelete : LongPrimaryKeyBase; diff --git a/Benchmarks.Core/Entities/SoftDeleted.cs b/Benchmarks.Core/Entities/SoftDeleted.cs deleted file mode 100644 index 7a3a9d1..0000000 --- a/Benchmarks.Core/Entities/SoftDeleted.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Benchmarks.Core.Entities; - -public interface ISoftDeletable -{ - bool IsDeleted { get; init; } - - DateTimeOffset? DeletedAtUtc { get; init; } -} - -public abstract class LongPrimaryKeyBase : IBaseEntity, ILongPrimaryKey -{ - public long Id { get; init; } - public string Text { get; init; } = string.Empty; - public DateTimeOffset CreatedAtUtc { get; init; } = DateTimeOffset.UtcNow; - public long LongInteger { get; init; } -} - -public sealed class SoftDeleted : LongPrimaryKeyBase, ISoftDeletable -{ - public bool IsDeleted { get; init; } - public DateTimeOffset? DeletedAtUtc { get; init; } -} - -public sealed class HardDeleted : LongPrimaryKeyBase; diff --git a/Benchmarks.Core/GlobalUsings.cs b/Benchmarks.Core/GlobalUsings.cs index 66c7ff1..4084705 100644 --- a/Benchmarks.Core/GlobalUsings.cs +++ b/Benchmarks.Core/GlobalUsings.cs @@ -3,5 +3,6 @@ global using Benchmarks.Core.Database.SqlServer; global using Benchmarks.Core.Entities; global using Microsoft.EntityFrameworkCore.Design; +global using Microsoft.EntityFrameworkCore.Diagnostics; global using System.ComponentModel; global using System.ComponentModel.DataAnnotations; diff --git a/Benchmarks.Core/Repositories/GuidPrimaryKeyRepository.cs b/Benchmarks.Core/Repositories/GuidPrimaryKeyRepository.cs index 488a4ee..92abd1b 100644 --- a/Benchmarks.Core/Repositories/GuidPrimaryKeyRepository.cs +++ b/Benchmarks.Core/Repositories/GuidPrimaryKeyRepository.cs @@ -2,8 +2,7 @@ public sealed class GuidPrimaryKeyRepository(DbServer dbServer, string connectionString) { - private static TEntity[] CreateEntities(int rowCount) - where TEntity : GuidPrimaryKeyBase, new() + private static TEntity[] Create(int rowCount) where TEntity : GuidPrimaryKeyBase, new() { var entities = new TEntity[rowCount]; @@ -23,12 +22,11 @@ private static TEntity[] CreateEntities(int rowCount) return entities; } - public async Task InsertEntitiesAsync(int rowCount) - where TEntity : GuidPrimaryKeyBase, new() + public async Task InsertAsync(int rowCount) where TEntity : GuidPrimaryKeyBase, new() { await using var dbContext = BenchmarkDbContextFactory.Create(dbServer, connectionString); await dbContext.Database.MigrateAsync(); - var entities = CreateEntities(rowCount); + var entities = Create(rowCount); await dbContext.Set().AddRangeAsync(entities); await dbContext.SaveChangesAsync(); } diff --git a/Benchmarks.Core/Repositories/SoftDeleteRepository.cs b/Benchmarks.Core/Repositories/SoftDeleteRepository.cs index 3fead9f..4c0462b 100644 --- a/Benchmarks.Core/Repositories/SoftDeleteRepository.cs +++ b/Benchmarks.Core/Repositories/SoftDeleteRepository.cs @@ -22,7 +22,7 @@ private static TEntity[] CreateEntities(int rowCount) return entities; } - public async Task InsertEntitiesAsync(int rowCount) + public async Task InsertAsync(int rowCount) where TEntity : LongPrimaryKeyBase, new() { await using var dbContext = BenchmarkDbContextFactory.Create(dbServer, connectionString); @@ -32,16 +32,17 @@ public async Task InsertEntitiesAsync(int rowCount) await dbContext.SaveChangesAsync(); } - public async Task SoftDeleteEntitiesAsync(int rowCount) + public async Task SoftDeleteAsync(int rowCount) + where TEntity : class, ISoftDeletable { await using var dbContext = BenchmarkDbContextFactory.Create(dbServer, connectionString); await dbContext.Database.MigrateAsync(); - var entities = dbContext.SoftDeletes.Take(rowCount); - dbContext.SoftDeletes.RemoveRange(entities); + var entities = dbContext.Set().Take(rowCount); + dbContext.Set().RemoveRange(entities); await dbContext.SaveChangesAsync(); } - public async Task HardDeleteEntitiesAsync(int rowCount) + public async Task HardDeleteAsync(int rowCount) { await using var dbContext = BenchmarkDbContextFactory.Create(dbServer, connectionString); await dbContext.Database.MigrateAsync(); diff --git a/Benchmarks/GlobalUsings.cs b/Benchmarks/GlobalUsings.cs index 30899cf..a058b5a 100644 --- a/Benchmarks/GlobalUsings.cs +++ b/Benchmarks/GlobalUsings.cs @@ -3,6 +3,5 @@ global using Benchmarks.Core.Database; global using Benchmarks.Core.Entities; global using Benchmarks.Core.Repositories; -global using System.ComponentModel; global using Testcontainers.MsSql; global using Testcontainers.PostgreSql; diff --git a/Benchmarks/GuidPrimaryKey.cs b/Benchmarks/GuidPrimaryKey.cs index 9995164..620f340 100644 --- a/Benchmarks/GuidPrimaryKey.cs +++ b/Benchmarks/GuidPrimaryKey.cs @@ -3,7 +3,7 @@ using GuidPrimaryKeyEntity = Benchmarks.Core.Entities.GuidPrimaryKey; [BenchmarkInfo( - description: "Benchmark using GUID based primary keys", + description: "Benchmark GUID based primary keys", links: [ "https://youtu.be/n17U7ntLMt4?si=lFUX24PlGOQrtIKR", "https://blog.novanet.no/careful-with-guid-as-clustered-index" @@ -14,51 +14,45 @@ public class GuidPrimaryKey [Params(1_000, 10_000)] public int RowCount { get; set; } - private readonly MsSqlContainer _sqlServerContainer = new MsSqlBuilder() + private readonly MsSqlContainer _sqlServer = new MsSqlBuilder() .WithImage("mcr.microsoft.com/mssql/server:latest") .Build(); - private readonly PostgreSqlContainer _postgresContainer = new PostgreSqlBuilder() + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder() .WithImage("postgres:latest") .Build(); [GlobalSetup] public async Task Setup() { - await _postgresContainer.StartAsync(); - await _sqlServerContainer.StartAsync(); + await _postgres.StartAsync(); + await _sqlServer.StartAsync(); } [Benchmark] public async Task InsertGuidPrimaryKeyPostgres() { - var repository = new GuidPrimaryKeyRepository( - DbServer.Postgres, - _postgresContainer.GetConnectionString()); - await repository.InsertEntitiesAsync(RowCount); + var repository = new GuidPrimaryKeyRepository(DbServer.Postgres, _postgres.GetConnectionString()); + await repository.InsertAsync(RowCount); } [Benchmark] public async Task InsertGuidPrimaryKeyWithClusteredIndexSqlServer() { - var repository = new GuidPrimaryKeyRepository( - DbServer.SqlServer, - _sqlServerContainer.GetConnectionString()); - await repository.InsertEntitiesAsync(RowCount); + var repository = new GuidPrimaryKeyRepository(DbServer.SqlServer, _sqlServer.GetConnectionString()); + await repository.InsertAsync(RowCount); } [Benchmark] public async Task InsertGuidPrimaryKeyWithNonClusteredIndexSqlServer() { - var repository = new GuidPrimaryKeyRepository( - DbServer.SqlServer, - _sqlServerContainer.GetConnectionString()); - await repository.InsertEntitiesAsync(RowCount); + var repository = new GuidPrimaryKeyRepository(DbServer.SqlServer, _sqlServer.GetConnectionString()); + await repository.InsertAsync(RowCount); } [GlobalCleanup] public async Task Cleanup() { - await _postgresContainer.StopAsync(); - await _sqlServerContainer.StopAsync(); + await _postgres.StopAsync(); + await _sqlServer.StopAsync(); } } diff --git a/Benchmarks/SoftDelete.cs b/Benchmarks/SoftDelete.cs index c66fa27..157bf4c 100644 --- a/Benchmarks/SoftDelete.cs +++ b/Benchmarks/SoftDelete.cs @@ -1,7 +1,7 @@ namespace Benchmarks; [BenchmarkInfo( - description: "Benchmark soft verses hard deletes", + description: "Benchmark hard verses soft deletes", links: ["https://www.milanjovanovic.tech/blog/implementing-soft-delete-with-ef-core"], Category.Database)] public class SoftDelete @@ -19,31 +19,49 @@ public class SoftDelete .WithImage("postgres:latest") .Build(); - private BenchmarkDbContext CreateDbContext(DbServer server) => - BenchmarkDbContextFactory.Create(server, server switch - { - DbServer.Postgres => _postgresContainer.GetConnectionString(), - DbServer.SqlServer => _sqlServerContainer.GetConnectionString(), - _ => throw new InvalidEnumArgumentException() - }); + private SoftDeleteRepository CreateRepository() => new(DbServer, DbServer switch + { + DbServer.Postgres => _postgresContainer.GetConnectionString(), + DbServer.SqlServer => _sqlServerContainer.GetConnectionString(), + _ => throw DbServer.InvalidEnumArgumentException() + }); [GlobalSetup] public async Task Setup() { await _postgresContainer.StartAsync(); await _sqlServerContainer.StartAsync(); + // TODO Register DI for SoftDeleteInterceptor - Will require refactoring + // services.AddSingleton(); + // services.AddDbContext( + // (serviceProvider, options) => options + // .UseSqlServer(_sqlServerContainer.GetConnectionString()) + // .AddInterceptors( + // serviceProvider.GetRequiredService())); + } + + [Benchmark] + public async Task HardDelete() + { + var repository = CreateRepository(); + await repository.InsertAsync(RowCount); + await repository.HardDeleteAsync(RowCount); } [Benchmark] - public async Task SaveHardDelete() + public async Task SoftDeleteWithQueryFilter() { - await using var dbContext = CreateDbContext(DbServer); + var repository = CreateRepository(); + await repository.InsertAsync(RowCount); + await repository.SoftDeleteAsync(RowCount); } [Benchmark] - public async Task SaveSoftDelete() + public async Task SoftDeleteWithoutQueryFilter() { - await using var dbContext = CreateDbContext(DbServer); + var repository = CreateRepository(); + await repository.InsertAsync(RowCount); + await repository.SoftDeleteAsync(RowCount); } [GlobalCleanup]