From bc9f55ef91875ae4b189c7c280a0bb4f04bcb79f Mon Sep 17 00:00:00 2001 From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com> Date: Sat, 2 May 2026 17:17:48 -0500 Subject: [PATCH] Phase 5: Add EF migration for soft delete columns; use shadow property for xmin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration adds IsDeleted/DeletedAt to Budgets, Incomes, Outgos, BudgetShares - xmin concurrency token configured as shadow property (uint, xid type) — not added as a column in the migration since xmin is a PostgreSQL system column - Budget.RowVersion property removed; concurrency tracked via shadow property only --- src/Budget.Core/Models/Budget.cs | 1 - .../Data/AppDbContext.cs | 3 +- ...08_AddSoftDeleteAndConcurrency.Designer.cs | 269 ++++++++++++++++++ ...60502221708_AddSoftDeleteAndConcurrency.cs | 103 +++++++ .../Migrations/AppDbContextModelSnapshot.cs | 32 ++- 5 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 src/Budget.Infrastructure/Data/Migrations/20260502221708_AddSoftDeleteAndConcurrency.Designer.cs create mode 100644 src/Budget.Infrastructure/Data/Migrations/20260502221708_AddSoftDeleteAndConcurrency.cs diff --git a/src/Budget.Core/Models/Budget.cs b/src/Budget.Core/Models/Budget.cs index c62f66a..ca6640f 100644 --- a/src/Budget.Core/Models/Budget.cs +++ b/src/Budget.Core/Models/Budget.cs @@ -10,7 +10,6 @@ public class Budget : ISoftDeletable public DateTimeOffset UpdatedAt { get; set; } public bool IsDeleted { get; set; } public DateTimeOffset? DeletedAt { get; set; } - public byte[] RowVersion { get; set; } = []; public List Incomes { get; set; } = []; public List Outgos { get; set; } = []; diff --git a/src/Budget.Infrastructure/Data/AppDbContext.cs b/src/Budget.Infrastructure/Data/AppDbContext.cs index 3d6ea0b..65ab97b 100644 --- a/src/Budget.Infrastructure/Data/AppDbContext.cs +++ b/src/Budget.Infrastructure/Data/AppDbContext.cs @@ -23,8 +23,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op b.HasMany(x => x.Outgos).WithOne(o => o.Budget).HasForeignKey(o => o.BudgetId).OnDelete(DeleteBehavior.Cascade); b.HasMany(x => x.Shares).WithOne(s => s.Budget).HasForeignKey(s => s.BudgetId).OnDelete(DeleteBehavior.Cascade); b.HasQueryFilter(x => !x.IsDeleted); - b.Property(e => e.RowVersion) - .HasColumnName("xmin") + b.Property("xmin") .HasColumnType("xid") .ValueGeneratedOnAddOrUpdate() .IsConcurrencyToken(); diff --git a/src/Budget.Infrastructure/Data/Migrations/20260502221708_AddSoftDeleteAndConcurrency.Designer.cs b/src/Budget.Infrastructure/Data/Migrations/20260502221708_AddSoftDeleteAndConcurrency.Designer.cs new file mode 100644 index 0000000..7c09e8c --- /dev/null +++ b/src/Budget.Infrastructure/Data/Migrations/20260502221708_AddSoftDeleteAndConcurrency.Designer.cs @@ -0,0 +1,269 @@ +// +using System; +using Budget.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Budget.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260502221708_AddSoftDeleteAndConcurrency")] + partial class AddSoftDeleteAndConcurrency + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Budget.Core.Models.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveTaxRate") + .HasPrecision(5, 4) + .HasColumnType("numeric(5,4)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.ToTable("Budgets"); + }); + + modelBuilder.Entity("Budget.Core.Models.BudgetShare", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsPending") + .HasColumnType("boolean"); + + b.Property("Permission") + .HasColumnType("integer"); + + b.Property("SharedWithEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SharedWithUserId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId", "SharedWithEmail") + .IsUnique(); + + b.ToTable("BudgetShares"); + }); + + modelBuilder.Entity("Budget.Core.Models.Income", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Frequency") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.ToTable("Incomes"); + }); + + modelBuilder.Entity("Budget.Core.Models.KnownUser", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("KnownUsers"); + }); + + modelBuilder.Entity("Budget.Core.Models.Outgo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Frequency") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PaymentSource") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.ToTable("Outgos"); + }); + + modelBuilder.Entity("Budget.Core.Models.BudgetShare", b => + { + b.HasOne("Budget.Core.Models.Budget", "Budget") + .WithMany("Shares") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + }); + + modelBuilder.Entity("Budget.Core.Models.Income", b => + { + b.HasOne("Budget.Core.Models.Budget", "Budget") + .WithMany("Incomes") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + }); + + modelBuilder.Entity("Budget.Core.Models.Outgo", b => + { + b.HasOne("Budget.Core.Models.Budget", "Budget") + .WithMany("Outgos") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + }); + + modelBuilder.Entity("Budget.Core.Models.Budget", b => + { + b.Navigation("Incomes"); + + b.Navigation("Outgos"); + + b.Navigation("Shares"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Budget.Infrastructure/Data/Migrations/20260502221708_AddSoftDeleteAndConcurrency.cs b/src/Budget.Infrastructure/Data/Migrations/20260502221708_AddSoftDeleteAndConcurrency.cs new file mode 100644 index 0000000..c95d8f5 --- /dev/null +++ b/src/Budget.Infrastructure/Data/Migrations/20260502221708_AddSoftDeleteAndConcurrency.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Budget.Infrastructure.Data.Migrations +{ + /// + public partial class AddSoftDeleteAndConcurrency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "Outgos", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Outgos", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "Incomes", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Incomes", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "BudgetShares", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "BudgetShares", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "Budgets", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Budgets", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "Outgos"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Outgos"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "Incomes"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Incomes"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "BudgetShares"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "BudgetShares"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "Budgets"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Budgets"); + } + } +} diff --git a/src/Budget.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Budget.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 88ee802..57c2f6f 100644 --- a/src/Budget.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Budget.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Budget.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -31,10 +31,16 @@ namespace Budget.Infrastructure.Data.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + b.Property("EffectiveTaxRate") .HasPrecision(5, 4) .HasColumnType("numeric(5,4)"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -48,6 +54,12 @@ namespace Budget.Infrastructure.Data.Migrations b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + b.HasKey("Id"); b.ToTable("Budgets"); @@ -65,6 +77,12 @@ namespace Budget.Infrastructure.Data.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("IsPending") .HasColumnType("boolean"); @@ -101,9 +119,15 @@ namespace Budget.Infrastructure.Data.Migrations b.Property("BudgetId") .HasColumnType("uuid"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Frequency") .HasColumnType("integer"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -160,9 +184,15 @@ namespace Budget.Infrastructure.Data.Migrations .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Frequency") .HasColumnType("integer"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("Name") .IsRequired() .HasMaxLength(200)