Skip to content

Commit 6a67e6b

Browse files
committed
perf(04-01): eliminate N+1 on endpoint list queries via Select projection
- Add EndpointListItem projection DTO to EndpointRepository - Rewrite ListAllAsync and ListByAppIdAsync with Select projection (no Include EventTypes) - Update DashboardEndpointController.ListEndpoints to map from EndpointListItem - Add ToDto(EndpointListItem) overload in ApiResponseMapper for EndpointsController compatibility - API response shapes unchanged for both dashboard and public endpoints
1 parent 5fc2b42 commit 6a67e6b

3 files changed

Lines changed: 79 additions & 12 deletions

File tree

src/WebhookEngine.API/Contracts/ApiResponseDtos.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text.Json;
22
using WebhookEngine.Core.Entities;
3+
using WebhookEngine.Infrastructure.Repositories;
34
using EndpointEntity = WebhookEngine.Core.Entities.Endpoint;
45

56
namespace WebhookEngine.API.Contracts;
@@ -94,6 +95,24 @@ public static EndpointResponseDto ToDto(this EndpointEntity endpoint)
9495
};
9596
}
9697

98+
public static EndpointResponseDto ToDto(this EndpointListItem endpoint)
99+
{
100+
return new EndpointResponseDto
101+
{
102+
Id = endpoint.Id,
103+
AppId = endpoint.AppId,
104+
Url = endpoint.Url,
105+
Description = endpoint.Description,
106+
Status = endpoint.Status.ToString().ToLowerInvariant(),
107+
CustomHeadersJson = JsonValueParser.ParseOrEmptyObject(endpoint.CustomHeadersJson),
108+
SecretOverride = endpoint.SecretOverride,
109+
MetadataJson = JsonValueParser.ParseOrEmptyObject(endpoint.MetadataJson),
110+
FilterEventTypes = endpoint.EventTypeIds,
111+
CreatedAt = endpoint.CreatedAt,
112+
UpdatedAt = endpoint.UpdatedAt
113+
};
114+
}
115+
97116
public static MessageResponseDto ToDto(this Message message)
98117
{
99118
return new MessageResponseDto

src/WebhookEngine.API/Controllers/DashboardEndpointController.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ public async Task<IActionResult> ListEndpoints(
5454
{
5555
id = e.Id,
5656
appId = e.AppId,
57-
appName = e.Application?.Name,
57+
appName = e.AppName,
5858
url = e.Url,
5959
description = e.Description,
6060
status = e.Status.ToString().ToLowerInvariant(),
61-
circuitState = e.Health?.CircuitState.ToString().ToLowerInvariant() ?? "closed",
62-
eventTypes = e.EventTypes.Select(et => et.Name).ToList(),
63-
eventTypeIds = e.EventTypes.Select(et => et.Id).ToList(),
61+
circuitState = (e.CircuitState ?? "closed").ToLowerInvariant(),
62+
eventTypes = e.EventTypeNames,
63+
eventTypeIds = e.EventTypeIds,
6464
createdAt = e.CreatedAt,
6565
updatedAt = e.UpdatedAt
6666
}),

src/WebhookEngine.Infrastructure/Repositories/EndpointRepository.cs

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public EndpointRepository(WebhookDbContext dbContext)
3232
.FirstOrDefaultAsync(e => e.AppId == appId && e.Id == id, ct);
3333
}
3434

35-
public async Task<List<Endpoint>> ListByAppIdAsync(
35+
public async Task<List<EndpointListItem>> ListByAppIdAsync(
3636
Guid appId,
3737
EndpointStatus? status,
3838
int page,
@@ -41,8 +41,6 @@ public async Task<List<Endpoint>> ListByAppIdAsync(
4141
{
4242
var query = _dbContext.Endpoints
4343
.AsNoTracking()
44-
.Include(e => e.Health)
45-
.Include(e => e.EventTypes)
4644
.Where(e => e.AppId == appId)
4745
.AsQueryable();
4846

@@ -52,6 +50,23 @@ public async Task<List<Endpoint>> ListByAppIdAsync(
5250
}
5351

5452
return await query
53+
.Select(e => new EndpointListItem
54+
{
55+
Id = e.Id,
56+
AppId = e.AppId,
57+
AppName = e.Application != null ? e.Application.Name : null,
58+
Url = e.Url,
59+
Description = e.Description,
60+
Status = e.Status,
61+
CircuitState = e.Health != null ? e.Health.CircuitState.ToString() : null,
62+
CustomHeadersJson = e.CustomHeadersJson,
63+
SecretOverride = e.SecretOverride,
64+
MetadataJson = e.MetadataJson,
65+
EventTypeNames = e.EventTypes.Select(et => et.Name).ToList(),
66+
EventTypeIds = e.EventTypes.Select(et => et.Id).ToList(),
67+
CreatedAt = e.CreatedAt,
68+
UpdatedAt = e.UpdatedAt
69+
})
5570
.OrderByDescending(e => e.CreatedAt)
5671
.Skip((page - 1) * pageSize)
5772
.Take(pageSize)
@@ -110,9 +125,10 @@ await _dbContext.Endpoints
110125
}
111126

112127
/// <summary>
113-
/// Cross-app list for dashboard admin — returns endpoints across all applications.
128+
/// Cross-app list for dashboard admin — returns endpoints across all applications
129+
/// using Select projection to avoid N+1 on EventTypes.
114130
/// </summary>
115-
public async Task<List<Endpoint>> ListAllAsync(
131+
public async Task<List<EndpointListItem>> ListAllAsync(
116132
Guid? appId,
117133
EndpointStatus? status,
118134
int page,
@@ -121,9 +137,6 @@ public async Task<List<Endpoint>> ListAllAsync(
121137
{
122138
var query = _dbContext.Endpoints
123139
.AsNoTracking()
124-
.Include(e => e.Application)
125-
.Include(e => e.Health)
126-
.Include(e => e.EventTypes)
127140
.AsQueryable();
128141

129142
if (appId.HasValue)
@@ -133,6 +146,23 @@ public async Task<List<Endpoint>> ListAllAsync(
133146
query = query.Where(e => e.Status == status.Value);
134147

135148
return await query
149+
.Select(e => new EndpointListItem
150+
{
151+
Id = e.Id,
152+
AppId = e.AppId,
153+
AppName = e.Application != null ? e.Application.Name : null,
154+
Url = e.Url,
155+
Description = e.Description,
156+
Status = e.Status,
157+
CircuitState = e.Health != null ? e.Health.CircuitState.ToString() : null,
158+
CustomHeadersJson = e.CustomHeadersJson,
159+
SecretOverride = e.SecretOverride,
160+
MetadataJson = e.MetadataJson,
161+
EventTypeNames = e.EventTypes.Select(et => et.Name).ToList(),
162+
EventTypeIds = e.EventTypes.Select(et => et.Id).ToList(),
163+
CreatedAt = e.CreatedAt,
164+
UpdatedAt = e.UpdatedAt
165+
})
136166
.OrderByDescending(e => e.CreatedAt)
137167
.Skip((page - 1) * pageSize)
138168
.Take(pageSize)
@@ -152,3 +182,21 @@ public async Task<int> CountAllAsync(Guid? appId, EndpointStatus? status, Cancel
152182
return await query.CountAsync(ct);
153183
}
154184
}
185+
186+
public record EndpointListItem
187+
{
188+
public Guid Id { get; init; }
189+
public Guid AppId { get; init; }
190+
public string? AppName { get; init; }
191+
public string Url { get; init; } = string.Empty;
192+
public string? Description { get; init; }
193+
public EndpointStatus Status { get; init; }
194+
public string? CircuitState { get; init; }
195+
public string CustomHeadersJson { get; init; } = "{}";
196+
public string? SecretOverride { get; init; }
197+
public string MetadataJson { get; init; } = "{}";
198+
public List<string> EventTypeNames { get; init; } = [];
199+
public List<Guid> EventTypeIds { get; init; } = [];
200+
public DateTime CreatedAt { get; init; }
201+
public DateTime UpdatedAt { get; init; }
202+
}

0 commit comments

Comments
 (0)