diff --git a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs index 5d045906..46cdcbae 100644 --- a/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs +++ b/backend/MenuApi.Integration.Tests/RecipeIntegrationTests.cs @@ -120,6 +120,51 @@ 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); + } + + [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/Exceptions/DbUpdateExceptionExtensions.cs b/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs new file mode 100644 index 00000000..2a50ae8e --- /dev/null +++ b/backend/MenuApi/Exceptions/DbUpdateExceptionExtensions.cs @@ -0,0 +1,23 @@ +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); + } + + 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 83cef2be..66418786 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,33 @@ 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()) + { + // 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 + .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..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; @@ -47,7 +48,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); } @@ -114,9 +123,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 (SqlException ex) when (ex.IsUniqueConstraintViolation()) + { + throw new BusinessValidationException($"A recipe named '{name.Value}' already exists."); + } } } 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..6b83effe --- /dev/null +++ b/backend/MenuDB/Migrations/20260522201428_AddUniqueNameConstraints.cs @@ -0,0 +1,91 @@ +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.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", + 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(