Skip to content

Deduplicate set-like junction-table rows before write#1020

Open
Copilot wants to merge 6 commits intomainfrom
copilot/add-dedupe-before-write
Open

Deduplicate set-like junction-table rows before write#1020
Copilot wants to merge 6 commits intomainfrom
copilot/add-dedupe-before-write

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 24, 2026

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 duplicate UnitIds

  • .Distinct() on UnitIds before building IngredientUnitEntity rows.

NewRecipeValidator — reject conflicting ingredient duplicates

  • New rule: if the same (Name, Unit) pair appears with different Amount values in one payload → 400 Bad Request. Exact duplicates (same name + unit + amount) still pass.
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);

RecipeService — deduplicate before upsert

  • Extracted DeduplicateIngredients() helper: maps view-model → DB model, then groups by (IngredientName, UnitName) taking the first occurrence per key.
  • Both CreateRecipeAsync and UpdateRecipeAsync call this before UpsertRecipeIngredientsAsync.

Tests

  • Validator: conflicting duplicates fail; exact duplicates pass; same-name different-unit passes.
  • Service: create/update with exact duplicates verifies a single entry reaches the repository.
  • Integration: duplicate UnitIds on ingredient create; exact-dup recipe create/update succeeds; conflicting-dup recipe create/update returns 400.

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
    • Triggering command: /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)
    • Triggering command: /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)
    • Triggering command: /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:

Copilot AI requested review from Copilot and removed request for Copilot April 24, 2026 21:15
Copilot AI linked an issue Apr 24, 2026 that may be closed by this pull request
- 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>
Copilot AI requested review from Copilot and removed request for Copilot April 24, 2026 21:20
Copilot AI requested review from Copilot and removed request for Copilot April 24, 2026 21:23
Copilot AI changed the title [WIP] Add dedupe-before-write behavior for collection and junction-table paths Deduplicate set-like junction-table rows before write Apr 24, 2026
Copilot AI requested a review from dgee2 April 24, 2026 21:24
Comment thread backend/MenuApi/Services/RecipeService.cs Outdated
Copilot AI review requested due to automatic review settings April 24, 2026 22:36
@dgee2 dgee2 marked this pull request as ready for review April 24, 2026 22:36
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.UnitIds in IngredientRepository before creating IngredientUnitEntity rows.
  • Add a NewRecipeValidator rule to reject duplicate (IngredientName, Unit) entries when amounts conflict.
  • Deduplicate recipe ingredients in RecipeService prior to calling UpsertRecipeIngredientsAsync, 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.

Comment thread backend/MenuApi/Validation/NewRecipeValidator.cs
dgee2 added 2 commits April 24, 2026 23:49
- 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'.
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 25 to +37
.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)
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.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))

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prevent duplicate inserts for set-like child/link rows

4 participants