Deduplicate set-like junction-table rows before write#1020
Deduplicate set-like junction-table rows before write#1020
Conversation
- IngredientRepository: Distinct() on UnitIds before creating IngredientUnitEntity rows - NewRecipeValidator: reject conflicting (Name, Unit) pairs with different amounts (400) - RecipeService: silently collapse exact-duplicate (IngredientName, UnitName) pairs before UpsertRecipeIngredientsAsync on both create and update - Unit tests: validator conflict/exact-dup coverage + service dedup coverage - Integration tests: ingredient dup-UnitIds, recipe exact-dup, recipe conflict, update exact-dup, update conflict Agent-Logs-Url: https://github.com/dgee2/Menu/sessions/d400051e-f123-4941-85c6-3a3c627a2ff8 Co-authored-by: dgee2 <5671841+dgee2@users.noreply.github.com>
…ze filtered list Agent-Logs-Url: https://github.com/dgee2/Menu/sessions/d400051e-f123-4941-85c6-3a3c627a2ff8 Co-authored-by: dgee2 <5671841+dgee2@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR prevents database-level failures from duplicate rows in set-like junction tables by deduplicating exact duplicates before writes and explicitly rejecting conflicting duplicates within a single request payload.
Changes:
- Deduplicate
NewIngredient.UnitIdsinIngredientRepositorybefore creatingIngredientUnitEntityrows. - Add a
NewRecipeValidatorrule to reject duplicate(IngredientName, Unit)entries when amounts conflict. - Deduplicate recipe ingredients in
RecipeServiceprior to callingUpsertRecipeIngredientsAsync, and add unit/integration tests covering the scenarios.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| backend/MenuApi/Validation/NewRecipeValidator.cs | Adds validation to reject conflicting duplicate ingredient entries. |
| backend/MenuApi/Services/RecipeService.cs | Deduplicates ingredients before upsert via a helper method. |
| backend/MenuApi/Repositories/IngredientRepository.cs | Deduplicates UnitIds before creating join-table entities. |
| backend/MenuApi.Tests/Validation/NewRecipeValidatorTests.cs | Adds unit tests for exact vs conflicting duplicate ingredient validation. |
| backend/MenuApi.Tests/Services/RecipeServiceTests.cs | Adds service tests ensuring deduplication happens before repository upsert. |
| backend/MenuApi.Integration.Tests/RecipeWithIngredientsIntegrationTests.cs | Adds integration tests for duplicate ingredient behavior on create/update. |
| backend/MenuApi.Integration.Tests/IngredientIntegrationTests.cs | Adds integration test for duplicate UnitIds behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Move ViewModelMapper.Map out of DeduplicateIngredients; callers now own the mapping step so the helper only deduplicates DB model items (per dgee2 review comment) - Guard against null elements in NewRecipeValidator Must block to prevent NullReferenceException on payloads containing null ingredients (per copilot-pull-request-reviewer comment)
Three new test methods created StringContent without using, causing CodeQL 'Missing Dispose call on local IDisposable' alerts. Changed all three to 'using var content'.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .SetValidator(new RecipeIngredientValidator()); | ||
|
|
||
| RuleFor(x => x.Ingredients) | ||
| .Must(items => | ||
| { | ||
| var initialized = items | ||
| .Where(i => i is not null && i.Name.IsInitialized() && i.Unit.IsInitialized() && i.Amount.IsInitialized()) | ||
| .ToList(); | ||
| return !initialized | ||
| .GroupBy(i => new { IngredientName = i.Name.Value, UnitName = i.Unit.Value }) | ||
| .Any(g => g.Select(i => i.Amount.Value).Distinct().Count() > 1); | ||
| }) | ||
| .When(x => x.Ingredients is not null) |
There was a problem hiding this comment.
The new duplicate-detection rule filters out null ingredient entries (i is not null), but RuleForEach(x => x.Ingredients).SetValidator(...) does not validate null elements either. This means a payload like "ingredients": [null] can pass validation and then fail later (e.g., during mapping) with a 500. Consider adding an explicit RuleForEach(x => x.Ingredients).NotNull() (with a clear message) before/alongside SetValidator, and then you can drop the need to special-case null here.
| .SetValidator(new RecipeIngredientValidator()); | |
| RuleFor(x => x.Ingredients) | |
| .Must(items => | |
| { | |
| var initialized = items | |
| .Where(i => i is not null && i.Name.IsInitialized() && i.Unit.IsInitialized() && i.Amount.IsInitialized()) | |
| .ToList(); | |
| return !initialized | |
| .GroupBy(i => new { IngredientName = i.Name.Value, UnitName = i.Unit.Value }) | |
| .Any(g => g.Select(i => i.Amount.Value).Distinct().Count() > 1); | |
| }) | |
| .When(x => x.Ingredients is not null) | |
| .NotNull() | |
| .WithMessage("'Ingredients' must not contain null items."); | |
| RuleForEach(x => x.Ingredients) | |
| .SetValidator(new RecipeIngredientValidator()); | |
| RuleFor(x => x.Ingredients) | |
| .Must(items => | |
| { | |
| var initialized = items | |
| .Where(i => i.Name.IsInitialized() && i.Unit.IsInitialized() && i.Amount.IsInitialized()) | |
| .ToList(); | |
| return !initialized | |
| .GroupBy(i => new { IngredientName = i.Name.Value, UnitName = i.Unit.Value }) | |
| .Any(g => g.Select(i => i.Amount.Value).Distinct().Count() > 1); | |
| }) | |
| .When(x => x.Ingredients is not null && x.Ingredients.All(i => i is not null)) |



Duplicate entries in a single request payload could attempt to insert rows with the same composite PK (
IngredientUnit,RecipeIngredient), failing at the DB level. Conflicting duplicates (same key, different amount) had no explicit rejection path.Changes
IngredientRepository— silent collapse of duplicateUnitIds.Distinct()onUnitIdsbefore buildingIngredientUnitEntityrows.NewRecipeValidator— reject conflicting ingredient duplicates(Name, Unit)pair appears with differentAmountvalues in one payload →400 Bad Request. Exact duplicates (same name + unit + amount) still pass.RecipeService— deduplicate before upsertDeduplicateIngredients()helper: maps view-model → DB model, then groups by(IngredientName, UnitName)taking the first occurrence per key.CreateRecipeAsyncandUpdateRecipeAsynccall this beforeUpsertRecipeIngredientsAsync.Tests
UnitIdson ingredient create; exact-dup recipe create/update succeeds; conflicting-dup recipe create/update returns400.Warning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
rt.services.visualstudio.com/home/REDACTED/work/Menu/Menu/backend/MenuApi.Tests/bin/Debug/net10.0/MenuApi.Tests /home/REDACTED/work/Menu/Menu/backend/MenuApi.Tests/bin/Debug/net10.0/MenuApi.Tests --server dotnettestcli --dotnet-test-pipe /tmp/31c3c9c87a3f4193aee3cea3969b5c20(dns block)/home/REDACTED/work/Menu/Menu/backend/MenuApi.Tests/bin/Debug/net10.0/MenuApi.Tests /home/REDACTED/work/Menu/Menu/backend/MenuApi.Tests/bin/Debug/net10.0/MenuApi.Tests --server dotnettestcli --dotnet-test-pipe /tmp/181b240cb8c94fb3953fb93cf924e9af ACCEPT(dns block)/home/REDACTED/work/Menu/Menu/backend/MenuApi.Tests/bin/Debug/net10.0/MenuApi.Tests /home/REDACTED/work/Menu/Menu/backend/MenuApi.Tests/bin/Debug/net10.0/MenuApi.Tests --server dotnettestcli --dotnet-test-pipe /tmp/e0191bcc64c54c06aa6389afc638acc6(dns block)If you need me to access, download, or install something from one of these locations, you can either: