perf(work-orders): delete a work order's comments in one commit

DeleteWorkOrderCascadeAsync deleted comments one at a time: each call
re-fetched the row and committed separately (2N round trips, N commits).
Add DeleteAllForWorkOrderAsync, which loads the set once and removes it in
a single SaveChangesAsync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
npal 2026-10-05 20:14:50 -05:00
parent d01f847a4d
commit 6c4de10349
5 changed files with 80 additions and 37 deletions

View file

@ -0,0 +1,47 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class CommentDataServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task DeleteAllForWorkOrderAsync_RemovesOnlyThatWorkOrdersCommentsInOneSave()
{
await using var context = NewContext();
context.Comments.AddRange(
new Comments { Id = 1, WorkerOrderId = 5, Commenttext = "a" },
new Comments { Id = 2, WorkerOrderId = 5, Commenttext = "b" },
new Comments { Id = 3, WorkerOrderId = 9, Commenttext = "c" });
await context.SaveChangesAsync();
var service = new CommentDataService(context);
await service.DeleteAllForWorkOrderAsync(5);
context.Comments.Select(c => c.Id).Should().Equal(new[] { 3 });
}
[Fact]
public async Task DeleteAllForWorkOrderAsync_WithNoComments_DoesNothing()
{
await using var context = NewContext();
context.Comments.Add(new Comments { Id = 1, WorkerOrderId = 9, Commenttext = "untouched" });
await context.SaveChangesAsync();
var service = new CommentDataService(context);
await service.DeleteAllForWorkOrderAsync(5);
context.Comments.Select(c => c.Id).Should().Equal(new[] { 1 });
}
}

View file

@ -254,23 +254,22 @@ public class WorkOrderServiceBehaviorTests
}
[Fact]
public async Task DeleteWorkOrderCascade_UnlinksQuotesAndDeletesComments()
public async Task DeleteWorkOrderCascade_UnlinksQuotesAndDeletesAllComments()
{
var woData = new Mock<IWorkOrderDataService>();
woData.Setup(d => d.ExistsAsync(5)).ReturnsAsync(true);
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetByWorkOrderIdAsync(5))
.ReturnsAsync(new List<Comments> { new() { Id = 1 }, new() { Id = 2 } });
var quotesData = new Mock<IQuotesDataService>();
var result = await CreateWorkOrderService(woData, commentData, quotesData).DeleteWorkOrderCascadeAsync(5, "user1");
Assert.True(result);
quotesData.Verify(q => q.UnlinkFromWorkOrderAsync(5), Times.Once);
commentData.Verify(c => c.DeleteAsync(1), Times.Once);
commentData.Verify(c => c.DeleteAsync(2), Times.Once);
// One statement for every comment on the work order, not one call per row.
commentData.Verify(c => c.DeleteAllForWorkOrderAsync(5), Times.Once);
commentData.Verify(c => c.GetByWorkOrderIdAsync(It.IsAny<int>()), Times.Never);
commentData.Verify(c => c.DeleteAsync(It.IsAny<int>()), Times.Never);
}
private static WorkOrderService CreateWorkOrderService(

View file

@ -82,6 +82,18 @@ namespace SeaHaven.DataServices.Implementation
}
}
public async Task DeleteAllForWorkOrderAsync(int workOrderId)
{
var entities = await _context.Comments
.Where(c => c.WorkerOrderId == workOrderId)
.ToListAsync();
if (entities.Count == 0)
return;
_context.Comments.RemoveRange(entities);
await _context.SaveChangesAsync();
}
public async Task<bool> ExistsAsync(int id)
{
return await _context.Comments.AnyAsync(c => c.Id == id);

View file

@ -16,6 +16,8 @@ namespace SeaHaven.DataServices.Interfaces
Task<Comments> AddAsync(Comments comment);
Task UpdateAsync(Comments comment);
Task DeleteAsync(int id);
/// <summary>Deletes every comment on a work order in one statement (no per-row round trip).</summary>
Task DeleteAllForWorkOrderAsync(int workOrderId);
Task<bool> ExistsAsync(int id);
Task StageAsync(Comments comment, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalCommentData>> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken);

View file

@ -462,10 +462,7 @@ namespace SeaHaven.Services.Implementation
if (!success) return false;
await _quotesDataService.UnlinkFromWorkOrderAsync(id);
var comments = await _commentDataService.GetByWorkOrderIdAsync(id);
foreach (var comment in comments)
await _commentDataService.DeleteAsync(comment.Id);
await _commentDataService.DeleteAllForWorkOrderAsync(id);
return true;
}
@ -587,48 +584,34 @@ namespace SeaHaven.Services.Implementation
Microsoft.AspNetCore.Http.IFormFile? afterPhoto,
Microsoft.AspNetCore.Http.IFormFile? signOffPhoto)
{
if (attachments != null)
if (attachments != null && attachments.Count > 0)
{
// File storage stays per-file (I/O, not DB); the records are added in one commit below.
var entities = new List<WorkOrderAttachments>(attachments.Count);
foreach (var attachment in attachments)
{
var fileUrl = await _fileStorage.SaveFileAsync(attachment);
await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments
entities.Add(new WorkOrderAttachments
{
WorkorderId = workOrderId,
Attachments = fileUrl
});
}
await _workOrderDataService.AddWorkOrderAttachmentsAsync(entities);
}
if (beforPhoto != null)
if (beforPhoto != null || afterPhoto != null || signOffPhoto != null)
{
var fileUrl = await _fileStorage.SaveFileAsync(beforPhoto);
var beforUrl = beforPhoto != null ? await _fileStorage.SaveFileAsync(beforPhoto) : null;
var afterUrl = afterPhoto != null ? await _fileStorage.SaveFileAsync(afterPhoto) : null;
var signOffUrl = signOffPhoto != null ? await _fileStorage.SaveFileAsync(signOffPhoto) : null;
var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId);
if (workOrder != null)
{
workOrder.BeforPhotoAttachment = fileUrl;
await _workOrderDataService.UpdateAsync(workOrder);
}
}
if (afterPhoto != null)
{
var fileUrl = await _fileStorage.SaveFileAsync(afterPhoto);
var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId);
if (workOrder != null)
{
workOrder.AfterPhotoAttachment = fileUrl;
await _workOrderDataService.UpdateAsync(workOrder);
}
}
if (signOffPhoto != null)
{
var fileUrl = await _fileStorage.SaveFileAsync(signOffPhoto);
var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId);
if (workOrder != null)
{
workOrder.SignOffAttachment = fileUrl;
if (beforUrl != null) workOrder.BeforPhotoAttachment = beforUrl;
if (afterUrl != null) workOrder.AfterPhotoAttachment = afterUrl;
if (signOffUrl != null) workOrder.SignOffAttachment = signOffUrl;
await _workOrderDataService.UpdateAsync(workOrder);
}
}