From 1de3920db41ad95918390fd8c1baab6b83cfa0f5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 24 Nov 2025 21:11:05 -0500 Subject: [PATCH] Perf: Add composite database indexes for common queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add indexes for: - (AccountId, Category) - account spending by category - (AccountId, Date) - account transaction timelines - (MerchantId, Date) - merchant spending trends - (CardId, Date) - card activity analysis - FileHashSha256 on Receipts - duplicate detection - (TransactionId, ReceiptDate) on Receipts - receipt lookups 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- MoneyMap/Data/MoneyMapContext.cs | 10 + ...1124231350_AddCompositeIndexes.Designer.cs | 620 ++++++++++++++++++ .../20251124231350_AddCompositeIndexes.cs | 90 +++ .../MoneyMapContextModelSnapshot.cs | 16 +- 4 files changed, 732 insertions(+), 4 deletions(-) create mode 100644 MoneyMap/Migrations/20251124231350_AddCompositeIndexes.Designer.cs create mode 100644 MoneyMap/Migrations/20251124231350_AddCompositeIndexes.cs diff --git a/MoneyMap/Data/MoneyMapContext.cs b/MoneyMap/Data/MoneyMapContext.cs index 2eb9897..ebd2bed 100644 --- a/MoneyMap/Data/MoneyMapContext.cs +++ b/MoneyMap/Data/MoneyMapContext.cs @@ -195,6 +195,16 @@ namespace MoneyMap.Data modelBuilder.Entity().HasIndex(x => x.Amount); modelBuilder.Entity().HasIndex(x => x.Category); modelBuilder.Entity().HasIndex(x => x.MerchantId); + + // Composite indexes for common query patterns + modelBuilder.Entity().HasIndex(x => new { x.AccountId, x.Category }); + modelBuilder.Entity().HasIndex(x => new { x.AccountId, x.Date }); + modelBuilder.Entity().HasIndex(x => new { x.MerchantId, x.Date }); + modelBuilder.Entity().HasIndex(x => new { x.CardId, x.Date }); + + // Receipt duplicate detection and lookup + modelBuilder.Entity().HasIndex(x => x.FileHashSha256); + modelBuilder.Entity().HasIndex(x => new { x.TransactionId, x.ReceiptDate }); } } } diff --git a/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.Designer.cs b/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.Designer.cs new file mode 100644 index 0000000..3650573 --- /dev/null +++ b/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.Designer.cs @@ -0,0 +1,620 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MoneyMap.Data; + +#nullable disable + +namespace MoneyMap.Migrations +{ + [DbContext(typeof(MoneyMapContext))] + [Migration("20251124231350_AddCompositeIndexes")] + partial class AddCompositeIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MoneyMap.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Issuer", "Last4", "Owner"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Merchants"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("FileHashSha256"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique() + .HasFilter("[TransactionId] IS NOT NULL"); + + b.HasIndex("TransactionId", "ReceiptDate"); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.Property("Voided") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Amount"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("MerchantId"); + + b.HasIndex("TransferToAccountId"); + + b.HasIndex("AccountId", "Category"); + + b.HasIndex("AccountId", "Date"); + + b.HasIndex("CardId", "Date"); + + b.HasIndex("MerchantId", "Date"); + + b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId") + .IsUnique() + .HasFilter("[CardId] IS NOT NULL"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("MerchantId") + .HasColumnType("int"); + + b.Property("Pattern") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MerchantId"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.HasOne("MoneyMap.Models.Account", "Account") + .WithMany("Cards") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.HasOne("MoneyMap.Models.Receipt", "Receipt") + .WithMany("LineItems") + .HasForeignKey("ReceiptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Receipt"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.HasOne("MoneyMap.Models.Receipt", "Receipt") + .WithMany("ParseLogs") + .HasForeignKey("ReceiptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Receipt"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.HasOne("MoneyMap.Models.Account", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MoneyMap.Models.Card", "Card") + .WithMany("Transactions") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("Transactions") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MoneyMap.Models.Account", "TransferToAccount") + .WithMany() + .HasForeignKey("TransferToAccountId"); + + b.Navigation("Account"); + + b.Navigation("Card"); + + b.Navigation("Merchant"); + + b.Navigation("TransferToAccount"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transfer", b => + { + b.HasOne("MoneyMap.Models.Account", "DestinationAccount") + .WithMany("DestinationTransfers") + .HasForeignKey("DestinationAccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction") + .WithMany() + .HasForeignKey("OriginalTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MoneyMap.Models.Account", "SourceAccount") + .WithMany("SourceTransfers") + .HasForeignKey("SourceAccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DestinationAccount"); + + b.Navigation("OriginalTransaction"); + + b.Navigation("SourceAccount"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.HasOne("MoneyMap.Models.Merchant", "Merchant") + .WithMany("CategoryMappings") + .HasForeignKey("MerchantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Merchant"); + }); + + modelBuilder.Entity("MoneyMap.Models.Account", b => + { + b.Navigation("Cards"); + + b.Navigation("DestinationTransfers"); + + b.Navigation("SourceTransfers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Merchant", b => + { + b.Navigation("CategoryMappings"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.Navigation("LineItems"); + + b.Navigation("ParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Navigation("Receipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.cs b/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.cs new file mode 100644 index 0000000..e21ebb3 --- /dev/null +++ b/MoneyMap/Migrations/20251124231350_AddCompositeIndexes.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddCompositeIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Transactions_AccountId", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_CardId", + table: "Transactions"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_AccountId_Category", + table: "Transactions", + columns: new[] { "AccountId", "Category" }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_AccountId_Date", + table: "Transactions", + columns: new[] { "AccountId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_CardId_Date", + table: "Transactions", + columns: new[] { "CardId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_MerchantId_Date", + table: "Transactions", + columns: new[] { "MerchantId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_FileHashSha256", + table: "Receipts", + column: "FileHashSha256"); + + migrationBuilder.CreateIndex( + name: "IX_Receipts_TransactionId_ReceiptDate", + table: "Receipts", + columns: new[] { "TransactionId", "ReceiptDate" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Transactions_AccountId_Category", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_AccountId_Date", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_CardId_Date", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_MerchantId_Date", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Receipts_FileHashSha256", + table: "Receipts"); + + migrationBuilder.DropIndex( + name: "IX_Receipts_TransactionId_ReceiptDate", + table: "Receipts"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_AccountId", + table: "Transactions", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_CardId", + table: "Transactions", + column: "CardId"); + } + } +} diff --git a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs index d5642d8..983b59a 100644 --- a/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs +++ b/MoneyMap/Migrations/MoneyMapContextModelSnapshot.cs @@ -183,10 +183,14 @@ namespace MoneyMap.Migrations b.HasKey("Id"); + b.HasIndex("FileHashSha256"); + b.HasIndex("TransactionId", "FileHashSha256") .IsUnique() .HasFilter("[TransactionId] IS NOT NULL"); + b.HasIndex("TransactionId", "ReceiptDate"); + b.ToTable("Receipts"); }); @@ -353,12 +357,8 @@ namespace MoneyMap.Migrations b.HasKey("Id"); - b.HasIndex("AccountId"); - b.HasIndex("Amount"); - b.HasIndex("CardId"); - b.HasIndex("Category"); b.HasIndex("Date"); @@ -367,6 +367,14 @@ namespace MoneyMap.Migrations b.HasIndex("TransferToAccountId"); + b.HasIndex("AccountId", "Category"); + + b.HasIndex("AccountId", "Date"); + + b.HasIndex("CardId", "Date"); + + b.HasIndex("MerchantId", "Date"); + b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId") .IsUnique() .HasFilter("[CardId] IS NOT NULL");