From f9e057dca645363e94267b3df6aae420cd9c90a5 Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Fri, 22 May 2026 21:15:02 +0100 Subject: [PATCH 1/3] Add unique constraints for Recipe.Name, Ingredient.Name, UnitType.Name, Unit.Name, Unit.Abbreviation - Add HasIndex(...).IsUnique() in MenuDbContext for all five business-unique columns - Unit.Abbreviation uses a filtered index (WHERE NOT NULL) to allow multiple nulls - Generate migration AddUniqueNameConstraints - Handle DbUpdateException unique constraint violations in IngredientRepository and RecipeRepository, surfacing them as BusinessValidationException (422) - Add DbUpdateExceptionExtensions.IsUniqueConstraintViolation() helper - Add integration tests for duplicate Recipe.Name and Ingredient.Name creation Closes #979 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RecipeIntegrationTests.cs | 19 ++ .../Exceptions/DbUpdateExceptionExtensions.cs | 17 + .../Repositories/IngredientRepository.cs | 21 +- .../MenuApi/Repositories/RecipeRepository.cs | 10 +- backend/MenuDB/MenuDbContext.cs | 5 + ...01428_AddUniqueNameConstraints.Designer.cs | 292 ++++++++++++++++++ ...20260522201428_AddUniqueNameConstraints.cs | 69 +++++ .../Migrations/MenuDbContextModelSnapshot.cs | 23 +- 8 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs create mode 100644 backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.Designer.cs create mode 100644 backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs diff --git a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs index 5d045906..65b47971 100644 --- a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs @@ -120,6 +120,25 @@ public async Task Create_Recipe_And_Get_Ingredients( ingredients![0].Name.Should().Be(ingredientName); } + [Theory, AutoData] + public async Task Create_Recipe_With_Duplicate_Name_Returns_UnprocessableEntity( + [NoAutoProperties] NewRecipe recipe, + [StringLength(500, MinimumLength = 1)] string recipeName, + [StringLength(50, MinimumLength = 1)] string ingredientName) + { + using var client = await fixture.GetHttpClient(); + await PostIngredientAsync(client, ingredientName); + recipe.Name = recipeName; + recipe.Ingredients = [new RecipeIngredient { Name = ingredientName, Unit = Grams, Amount = 100 }]; + + await PostRecipeAsync(client, recipe); + + using var requestContent = new StringContent(JsonSerializer.Serialize(recipe), Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/api/recipe", requestContent); + + await response.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + } + private static async Task PostIngredientAsync(HttpClient client, string name) { var body = new { name, unitIds = new[] { 4 } }; // Grams diff --git a/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs b/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs new file mode 100644 index 00000000..901dc7b1 --- /dev/null +++ b/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace MenuApi.Exceptions; + +public static class DbUpdateExceptionExtensions +{ + private const int SqlServerUniqueConstraintViolationError = 2627; + private const int SqlServerUniqueIndexViolationError = 2601; + + public static bool IsUniqueConstraintViolation(this DbUpdateException exception) + { + return exception.InnerException is SqlException sqlEx && + (sqlEx.Number == SqlServerUniqueConstraintViolationError || + sqlEx.Number == SqlServerUniqueIndexViolationError); + } +} diff --git a/backend/MenuApi/Repositories/IngredientRepository.cs b/backend/MenuApi/Repositories/IngredientRepository.cs index 83cef2be..477ff468 100644 --- a/backend/MenuApi/Repositories/IngredientRepository.cs +++ b/backend/MenuApi/Repositories/IngredientRepository.cs @@ -1,5 +1,6 @@ using MenuDB; using MenuDB.Data; +using MenuApi.Exceptions; using MenuApi.ValueObjects; using Microsoft.EntityFrameworkCore; @@ -49,7 +50,25 @@ public class IngredientRepository(MenuDbContext db) : IIngredientRepository .ToList(), }; db.Ingredients.Add(entity); - await db.SaveChangesAsync().ConfigureAwait(false); + try + { + await db.SaveChangesAsync().ConfigureAwait(false); + } + catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation()) + { + // Race condition: another request inserted the same name between our read and write. + // Re-query and return the now-existing ingredient. + var racedExisting = await db.Ingredients + .Where(i => i.Name == newIngredient.Name.Value) + .OrderBy(i => i.Id) + .Select(i => new IngredientProjection( + i.Id, + i.Name, + i.IngredientUnits.Select(iu => new UnitProjection(iu.Unit.Name, iu.Unit.Abbreviation, iu.Unit.UnitType.Name)))) + .FirstAsync() + .ConfigureAwait(false); + return MapToViewModel(racedExisting); + } var created = await db.Ingredients .Where(i => i.Id == entity.Id) diff --git a/backend/MenuApi/Repositories/RecipeRepository.cs b/backend/MenuApi/Repositories/RecipeRepository.cs index 332a00bf..18222df0 100644 --- a/backend/MenuApi/Repositories/RecipeRepository.cs +++ b/backend/MenuApi/Repositories/RecipeRepository.cs @@ -47,7 +47,15 @@ public async Task CreateRecipeAsync(RecipeName name) { var entity = new RecipeEntity { Name = name.Value }; db.Recipes.Add(entity); - await db.SaveChangesAsync().ConfigureAwait(false); + try + { + await db.SaveChangesAsync().ConfigureAwait(false); + } + catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation()) + { + throw new BusinessValidationException($"A recipe named '{name.Value}' already exists."); + } + return RecipeId.From(entity.Id); } diff --git a/backend/MenuDB/MenuDbContext.cs b/backend/MenuDB/MenuDbContext.cs index 61efd18b..a2863024 100644 --- a/backend/MenuDB/MenuDbContext.cs +++ b/backend/MenuDB/MenuDbContext.cs @@ -20,6 +20,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasKey(x => x.Id); e.Property(x => x.Id).UseIdentityColumn(); e.Property(x => x.Name).HasColumnType("varchar(500)").IsRequired(); + e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Recipe_Name"); }); modelBuilder.Entity(e => @@ -28,6 +29,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasKey(x => x.Id); e.Property(x => x.Id).UseIdentityColumn(); e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); + e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Ingredient_Name"); }); modelBuilder.Entity(e => @@ -36,6 +38,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasKey(x => x.Id); e.Property(x => x.Id).ValueGeneratedNever(); e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); + e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_UnitType_Name"); e.HasData( new UnitTypeEntity { Id = 1, Name = "Volume" }, new UnitTypeEntity { Id = 2, Name = "Quantity" }, @@ -49,6 +52,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.Property(x => x.Id).ValueGeneratedNever(); e.Property(x => x.Name).HasColumnType("varchar(50)").IsRequired(); e.Property(x => x.Abbreviation).HasColumnType("varchar(5)"); + e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_Unit_Name"); + e.HasIndex(x => x.Abbreviation).IsUnique().HasDatabaseName("UX_Unit_Abbreviation").HasFilter("[Abbreviation] IS NOT NULL"); e.HasOne(x => x.UnitType) .WithMany(x => x.Units) .HasForeignKey(x => x.UnitTypeId) diff --git a/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.Designer.cs b/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.Designer.cs new file mode 100644 index 00000000..75efb599 --- /dev/null +++ b/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.Designer.cs @@ -0,0 +1,292 @@ +// +using MenuDB; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MenuDB.Migrations +{ + [DbContext(typeof(MenuDbContext))] + [Migration("20260522201428_AddUniqueNameConstraints")] + partial class AddUniqueNameConstraints + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MenuDB.Data.IngredientEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Ingredient_Name"); + + b.ToTable("Ingredient", (string)null); + }); + + modelBuilder.Entity("MenuDB.Data.IngredientUnitEntity", b => + { + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.HasKey("IngredientId", "UnitId"); + + b.HasIndex("UnitId"); + + b.ToTable("IngredientUnit", (string)null); + }); + + modelBuilder.Entity("MenuDB.Data.RecipeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Recipe_Name"); + + b.ToTable("Recipe", (string)null); + }); + + modelBuilder.Entity("MenuDB.Data.RecipeIngredientEntity", b => + { + b.Property("RecipeId") + .HasColumnType("int"); + + b.Property("IngredientId") + .HasColumnType("int"); + + b.Property("UnitId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(10,4)"); + + b.HasKey("RecipeId", "IngredientId", "UnitId"); + + b.HasIndex("IngredientId"); + + b.HasIndex("UnitId"); + + b.ToTable("RecipeIngredient", (string)null); + }); + + modelBuilder.Entity("MenuDB.Data.UnitEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Abbreviation") + .HasColumnType("varchar(5)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("UnitTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Abbreviation") + .IsUnique() + .HasDatabaseName("UX_Unit_Abbreviation") + .HasFilter("[Abbreviation] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Unit_Name"); + + b.HasIndex("UnitTypeId"); + + b.ToTable("Unit", (string)null); + + b.HasData( + new + { + Id = 1, + Abbreviation = "ml", + Name = "Millilitres", + UnitTypeId = 1 + }, + new + { + Id = 2, + Abbreviation = "l", + Name = "Litres", + UnitTypeId = 1 + }, + new + { + Id = 3, + Name = "Quantity", + UnitTypeId = 2 + }, + new + { + Id = 4, + Abbreviation = "g", + Name = "Grams", + UnitTypeId = 3 + }, + new + { + Id = 5, + Abbreviation = "kg", + Name = "Kilograms", + UnitTypeId = 3 + }); + }); + + modelBuilder.Entity("MenuDB.Data.UnitTypeEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_UnitType_Name"); + + b.ToTable("UnitType", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Volume" + }, + new + { + Id = 2, + Name = "Quantity" + }, + new + { + Id = 3, + Name = "Weight" + }); + }); + + modelBuilder.Entity("MenuDB.Data.IngredientUnitEntity", b => + { + b.HasOne("MenuDB.Data.IngredientEntity", "Ingredient") + .WithMany("IngredientUnits") + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_IngredientUnit_ToIngredient"); + + b.HasOne("MenuDB.Data.UnitEntity", "Unit") + .WithMany("IngredientUnits") + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_IngredientUnit_ToUnit"); + + b.Navigation("Ingredient"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("MenuDB.Data.RecipeIngredientEntity", b => + { + b.HasOne("MenuDB.Data.IngredientEntity", "Ingredient") + .WithMany() + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MenuDB.Data.RecipeEntity", "Recipe") + .WithMany("RecipeIngredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_RecipeIngredient_ToRecipe"); + + b.HasOne("MenuDB.Data.UnitEntity", "Unit") + .WithMany() + .HasForeignKey("UnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("Recipe"); + + b.Navigation("Unit"); + }); + + modelBuilder.Entity("MenuDB.Data.UnitEntity", b => + { + b.HasOne("MenuDB.Data.UnitTypeEntity", "UnitType") + .WithMany("Units") + .HasForeignKey("UnitTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_Unit_ToUnitType"); + + b.Navigation("UnitType"); + }); + + modelBuilder.Entity("MenuDB.Data.IngredientEntity", b => + { + b.Navigation("IngredientUnits"); + }); + + modelBuilder.Entity("MenuDB.Data.RecipeEntity", b => + { + b.Navigation("RecipeIngredients"); + }); + + modelBuilder.Entity("MenuDB.Data.UnitEntity", b => + { + b.Navigation("IngredientUnits"); + }); + + modelBuilder.Entity("MenuDB.Data.UnitTypeEntity", b => + { + b.Navigation("Units"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs b/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs new file mode 100644 index 00000000..af658991 --- /dev/null +++ b/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MenuDB.Migrations +{ + /// + public partial class AddUniqueNameConstraints : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "UX_UnitType_Name", + table: "UnitType", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_Unit_Abbreviation", + table: "Unit", + column: "Abbreviation", + unique: true, + filter: "[Abbreviation] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_Unit_Name", + table: "Unit", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_Recipe_Name", + table: "Recipe", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_Ingredient_Name", + table: "Ingredient", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "UX_UnitType_Name", + table: "UnitType"); + + migrationBuilder.DropIndex( + name: "UX_Unit_Abbreviation", + table: "Unit"); + + migrationBuilder.DropIndex( + name: "UX_Unit_Name", + table: "Unit"); + + migrationBuilder.DropIndex( + name: "UX_Recipe_Name", + table: "Recipe"); + + migrationBuilder.DropIndex( + name: "UX_Ingredient_Name", + table: "Ingredient"); + } + } +} diff --git a/backend/MenuDB/Migrations/MenuDbContextModelSnapshot.cs b/backend/MenuDB/Migrations/MenuDbContextModelSnapshot.cs index c7e6427d..6e6d45c6 100644 --- a/backend/MenuDB/Migrations/MenuDbContextModelSnapshot.cs +++ b/backend/MenuDB/Migrations/MenuDbContextModelSnapshot.cs @@ -16,7 +16,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -35,6 +35,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Ingredient_Name"); + b.ToTable("Ingredient", (string)null); }); @@ -67,6 +71,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Recipe_Name"); + b.ToTable("Recipe", (string)null); }); @@ -110,6 +118,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Abbreviation") + .IsUnique() + .HasDatabaseName("UX_Unit_Abbreviation") + .HasFilter("[Abbreviation] IS NOT NULL"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_Unit_Name"); + b.HasIndex("UnitTypeId"); b.ToTable("Unit", (string)null); @@ -162,6 +179,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_UnitType_Name"); + b.ToTable("UnitType", (string)null); b.HasData( From 0680027b7e3099f15eae406c8097484ae09f9af6 Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Fri, 22 May 2026 23:00:04 +0100 Subject: [PATCH 2/3] Address PR review: migration guards, update-path unique handling, tests - Add pre-flight THROW guards in AddUniqueNameConstraints migration before creating UX_Recipe_Name and UX_Ingredient_Name indexes, so the migration fails with a clear actionable message if duplicate rows exist - Handle unique constraint violations in UpdateRecipeAsync by catching SqlException (2627/2601) directly (ExecuteUpdateAsync bypasses DbUpdateException) and surfacing as BusinessValidationException -> 422 - Add Update_Recipe_To_Duplicate_Name_Returns_UnprocessableEntity integration test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RecipeIntegrationTests.cs | 26 +++++++++++++++++++ .../MenuApi/Repositories/RecipeRepository.cs | 15 ++++++++--- ...20260522201428_AddUniqueNameConstraints.cs | 22 ++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs index 65b47971..46cdcbae 100644 --- a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs @@ -139,6 +139,32 @@ public async Task Create_Recipe_With_Duplicate_Name_Returns_UnprocessableEntity( await response.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); } + [Theory, AutoData] + public async Task Update_Recipe_To_Duplicate_Name_Returns_UnprocessableEntity( + [NoAutoProperties] NewRecipe recipe1, + [StringLength(500, MinimumLength = 1)] string recipeName1, + [NoAutoProperties] NewRecipe recipe2, + [StringLength(500, MinimumLength = 1)] string recipeName2, + [StringLength(50, MinimumLength = 1)] string ingredientName) + { + using var client = await fixture.GetHttpClient(); + await PostIngredientAsync(client, ingredientName); + recipe1.Name = recipeName1; + recipe1.Ingredients = [new RecipeIngredient { Name = ingredientName, Unit = Grams, Amount = 100 }]; + recipe2.Name = recipeName2; + recipe2.Ingredients = [new RecipeIngredient { Name = ingredientName, Unit = Grams, Amount = 200 }]; + + await PostRecipeAsync(client, recipe1); + var (id2, _) = await PostRecipeAsync(client, recipe2); + + // Try to rename recipe2 to recipe1's name - should be 422 + recipe2.Name = recipeName1; + using var requestContent = new StringContent(JsonSerializer.Serialize(recipe2), Encoding.UTF8, "application/json"); + using var response = await client.PutAsync($"/api/recipe/{id2}", requestContent); + + await response.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + } + private static async Task PostIngredientAsync(HttpClient client, string name) { var body = new { name, unitIds = new[] { 4 } }; // Grams diff --git a/backend/MenuApi/Repositories/RecipeRepository.cs b/backend/MenuApi/Repositories/RecipeRepository.cs index 18222df0..4512e641 100644 --- a/backend/MenuApi/Repositories/RecipeRepository.cs +++ b/backend/MenuApi/Repositories/RecipeRepository.cs @@ -122,9 +122,16 @@ public async Task UpsertRecipeIngredientsAsync(RecipeId recipeId, IEnumerable r.Id == recipeId.Value) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.Name, name.Value)) - .ConfigureAwait(false); + try + { + await db.Recipes + .Where(r => r.Id == recipeId.Value) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.Name, name.Value)) + .ConfigureAwait(false); + } + catch (Microsoft.Data.SqlClient.SqlException ex) when (ex.Number == 2627 || ex.Number == 2601) + { + throw new BusinessValidationException($"A recipe named '{name.Value}' already exists."); + } } } diff --git a/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs b/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs index af658991..6b83effe 100644 --- a/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs +++ b/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs @@ -29,12 +29,34 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "Name", unique: true); + migrationBuilder.Sql(@" + IF EXISTS ( + SELECT Name FROM Recipe + GROUP BY Name + HAVING COUNT(*) > 1 + ) + BEGIN + THROW 51000, 'Duplicate Recipe names exist. Deduplicate Recipe rows before running this migration.', 1; + END + "); + migrationBuilder.CreateIndex( name: "UX_Recipe_Name", table: "Recipe", column: "Name", unique: true); + migrationBuilder.Sql(@" + IF EXISTS ( + SELECT Name FROM Ingredient + GROUP BY Name + HAVING COUNT(*) > 1 + ) + BEGIN + THROW 51000, 'Duplicate Ingredient names exist. Deduplicate Ingredient rows before running this migration.', 1; + END + "); + migrationBuilder.CreateIndex( name: "UX_Ingredient_Name", table: "Ingredient", From e1431a9b0dc6707d1b30995d3dbfa8e03a2566e2 Mon Sep 17 00:00:00 2001 From: Daniel Gee Date: Fri, 22 May 2026 23:10:42 +0100 Subject: [PATCH 3/3] Address PR review: detach failed EF entities, centralize SQL error codes - IngredientRepository: detach entity and IngredientUnits before re-querying in race-condition catch block so the DbContext is left in a clean state - DbUpdateExceptionExtensions: add IsUniqueConstraintViolation overload for SqlException so error codes (2627/2601) are defined in one place only - RecipeRepository.UpdateRecipeAsync: use the new SqlException extension method instead of hardcoded error numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs | 6 ++++++ backend/MenuApi/Repositories/IngredientRepository.cs | 8 ++++++++ backend/MenuApi/Repositories/RecipeRepository.cs | 3 ++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs b/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs index 901dc7b1..2a50ae8e 100644 --- a/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs +++ b/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs @@ -14,4 +14,10 @@ public static bool IsUniqueConstraintViolation(this DbUpdateException exception) (sqlEx.Number == SqlServerUniqueConstraintViolationError || sqlEx.Number == SqlServerUniqueIndexViolationError); } + + public static bool IsUniqueConstraintViolation(this SqlException exception) + { + return exception.Number == SqlServerUniqueConstraintViolationError || + exception.Number == SqlServerUniqueIndexViolationError; + } } diff --git a/backend/MenuApi/Repositories/IngredientRepository.cs b/backend/MenuApi/Repositories/IngredientRepository.cs index 477ff468..66418786 100644 --- a/backend/MenuApi/Repositories/IngredientRepository.cs +++ b/backend/MenuApi/Repositories/IngredientRepository.cs @@ -56,6 +56,14 @@ public class IngredientRepository(MenuDbContext db) : IIngredientRepository } catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation()) { + // Detach the failed entities so EF doesn't retry the insert. + foreach (var unit in entity.IngredientUnits) + { + db.Entry(unit).State = EntityState.Detached; + } + + db.Entry(entity).State = EntityState.Detached; + // Race condition: another request inserted the same name between our read and write. // Re-query and return the now-existing ingredient. var racedExisting = await db.Ingredients diff --git a/backend/MenuApi/Repositories/RecipeRepository.cs b/backend/MenuApi/Repositories/RecipeRepository.cs index 4512e641..3438914c 100644 --- a/backend/MenuApi/Repositories/RecipeRepository.cs +++ b/backend/MenuApi/Repositories/RecipeRepository.cs @@ -4,6 +4,7 @@ using MenuApi.DBModel; using MenuApi.Exceptions; using MenuApi.ValueObjects; +using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; namespace MenuApi.Repositories; @@ -129,7 +130,7 @@ await db.Recipes .ExecuteUpdateAsync(s => s.SetProperty(r => r.Name, name.Value)) .ConfigureAwait(false); } - catch (Microsoft.Data.SqlClient.SqlException ex) when (ex.Number == 2627 || ex.Number == 2601) + catch (SqlException ex) when (ex.IsUniqueConstraintViolation()) { throw new BusinessValidationException($"A recipe named '{name.Value}' already exists."); }