build-flow.api/tests/BuildFlow.Workflow.Tests/Performance/FormAggregateQueryCountTests.cs
2026-07-24 08:49:29 +08:00

254 lines
8.9 KiB
C#

using BuildFlow.Workflow.Tests.Reliability;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Nodes;
using Workflow.Application.Form.Aggregates;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Xunit;
namespace BuildFlow.Workflow.Tests.Performance;
/// <summary>
/// 表单聚合刷新查询次数门禁,避免目标行和统计字段触发 N+1。
/// </summary>
[Collection(PostgresWorkflowCollection.Name)]
public class FormAggregateQueryCountTests(PostgresWorkflowFixture fixture)
{
[Fact]
public async Task AggregateRefreshUsesConstantSelectCountForManyRowsAndFields()
{
await ResetAsync();
var targetFormId = Guid.NewGuid();
var sourceFormId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
await using (var seed = fixture.CreateDbContext())
{
seed.FormDefinitions.AddRange(
new FormDefinition
{
Id = targetFormId,
Name = "费用汇总",
Code = "expense-main",
Version = 1,
Status = FormStatus.Published,
SchemaJson = AggregateSchema,
},
new FormDefinition
{
Id = sourceFormId,
Name = "费用明细",
Code = "expense-line",
Version = 1,
Status = FormStatus.Published,
SchemaJson = """{"type":"object","properties":{}}""",
});
seed.WorkflowInstances.Add(new WorkflowInstance
{
Id = instanceId,
DefinitionId = Guid.NewGuid(),
DefinitionVersion = 1,
Title = "聚合查询计数",
Status = InstanceStatus.Running,
InitiatorId = Guid.NewGuid(),
Variables = "{}",
});
for (var i = 0; i < 10; i++)
{
seed.FormData.Add(new global::Workflow.Domain.Entities.FormData
{
Id = Guid.NewGuid(),
FormDefinitionId = targetFormId,
InstanceId = instanceId,
DataJson = $$"""{"requestId":"REQ-{{i}}","lineTotal":0,"lineCount":0}""",
});
seed.FormData.AddRange(
CreateSourceRow(sourceFormId, instanceId, i, 10),
CreateSourceRow(sourceFormId, instanceId, i, 20));
}
await seed.SaveChangesAsync();
}
var interceptor = new CountingCommandInterceptor();
await using (var db = fixture.CreateDbContext(interceptor))
{
interceptor.Reset();
var service = new FormAggregateRefreshService(db);
await service.RefreshAsync(targetFormId, instanceId, CancellationToken.None);
interceptor.SelectCount.Should().Be(4);
}
await using var verify = fixture.CreateDbContext();
var targetRows = await verify.FormData
.Where(x => x.FormDefinitionId == targetFormId)
.ToListAsync();
targetRows.Should().HaveCount(10);
targetRows.Should().OnlyContain(x => HasExpectedAggregates(x));
var instance = await verify.WorkflowInstances.SingleAsync(x => x.Id == instanceId);
var variables = JsonNode.Parse(instance.Variables!)!.AsObject();
variables["lineTotal"]!.GetValue<decimal>().Should().Be(30);
variables["lineCount"]!.GetValue<decimal>().Should().Be(2);
}
[Fact]
public async Task AggregateRefreshSupportsSourceRowsFromTheTargetForm()
{
await ResetAsync();
var formDefinitionId = Guid.NewGuid();
var instanceId = Guid.NewGuid();
await using (var seed = fixture.CreateDbContext())
{
seed.FormDefinitions.Add(new FormDefinition
{
Id = formDefinitionId,
Name = "自聚合表单",
Code = "self-aggregate",
Version = 1,
Status = FormStatus.Published,
SchemaJson = SelfAggregateSchema,
});
seed.FormData.AddRange(
new global::Workflow.Domain.Entities.FormData
{
Id = Guid.NewGuid(),
FormDefinitionId = formDefinitionId,
InstanceId = instanceId,
DataJson = """{"requestId":"A","amount":10,"total":0}""",
},
new global::Workflow.Domain.Entities.FormData
{
Id = Guid.NewGuid(),
FormDefinitionId = formDefinitionId,
InstanceId = instanceId,
DataJson = """{"requestId":"B","amount":20,"total":0}""",
});
await seed.SaveChangesAsync();
}
await using (var db = fixture.CreateDbContext())
{
var service = new FormAggregateRefreshService(db);
await service.RefreshAsync(formDefinitionId, instanceId, CancellationToken.None);
}
await using var verify = fixture.CreateDbContext();
var rows = await verify.FormData
.Where(x => x.FormDefinitionId == formDefinitionId)
.ToListAsync();
rows.Should().Contain(x => HasTotal(x, 10));
rows.Should().Contain(x => HasTotal(x, 20));
}
private async Task ResetAsync()
{
await using var db = fixture.CreateDbContext();
await db.WorkflowTimerJobs.ExecuteDeleteAsync();
await db.WorkflowTasks.ExecuteDeleteAsync();
await db.WorkflowTokens.ExecuteDeleteAsync();
await db.FormData.ExecuteDeleteAsync();
await db.BuildFlowImportJobs.ExecuteDeleteAsync();
await db.WorkflowInstances.ExecuteDeleteAsync();
await db.FormDefinitions.ExecuteDeleteAsync();
}
private static global::Workflow.Domain.Entities.FormData CreateSourceRow(
Guid sourceFormId,
Guid instanceId,
int requestIndex,
int amount)
=> new()
{
Id = Guid.NewGuid(),
FormDefinitionId = sourceFormId,
InstanceId = instanceId,
DataJson = $$"""{"requestId":"REQ-{{requestIndex}}","amount":{{amount}}}""",
};
private static bool HasExpectedAggregates(
global::Workflow.Domain.Entities.FormData row)
{
var data = JsonNode.Parse(row.DataJson)!.AsObject();
return data["lineTotal"]!.GetValue<decimal>() == 30
&& data["lineCount"]!.GetValue<decimal>() == 2;
}
private static bool HasTotal(
global::Workflow.Domain.Entities.FormData row,
decimal expected)
{
var data = JsonNode.Parse(row.DataJson)!.AsObject();
return data["amount"]!.GetValue<decimal>() == expected
&& data["total"]!.GetValue<decimal>() == expected;
}
private const string AggregateSchema = """
{
"type": "object",
"properties": {
"requestId": { "type": "string", "title": "申请编号", "x-component": "Input" },
"lineTotal": {
"type": "number",
"title": "明细总金额",
"x-component": "InputNumber",
"x-field-kind": "aggregate",
"x-derived": {
"mode": "aggregate",
"source": {
"type": "formData",
"formDefinitionCode": "expense-line",
"relationPath": "requestId",
"relationValuePath": "requestId"
},
"aggregate": { "function": "sum", "fieldPath": "amount" }
}
},
"lineCount": {
"type": "number",
"title": "明细数量",
"x-component": "InputNumber",
"x-field-kind": "aggregate",
"x-derived": {
"mode": "aggregate",
"source": {
"type": "formData",
"formDefinitionCode": "expense-line",
"relationPath": "requestId",
"relationValuePath": "requestId"
},
"aggregate": { "function": "count" }
}
}
}
}
""";
private const string SelfAggregateSchema = """
{
"type": "object",
"properties": {
"requestId": { "type": "string", "x-component": "Input" },
"amount": { "type": "number", "x-component": "InputNumber" },
"total": {
"type": "number",
"x-component": "InputNumber",
"x-field-kind": "aggregate",
"x-derived": {
"mode": "aggregate",
"source": {
"type": "formData",
"formDefinitionCode": "self-aggregate",
"relationPath": "requestId",
"relationValuePath": "requestId"
},
"aggregate": { "function": "sum", "fieldPath": "amount" }
}
}
}
}
""";
}