Skip to content

Commit 0bbac7c

Browse files
calin-lupas_dsamcapscalin-lupas_dsamcaps
authored andcommitted
Enhance issue request management and webhook handling
- Introduced `IssueRequestRepository` and `IssueRequestEntity` classes for managing issue requests with CRUD operations. - Improved `SaveWebhookAsync` in `GitHubWebhookRepository` to handle webhook data more effectively. - Updated `SqlDbStorageContext` with a new `CreateConnectionAsync` method and enhanced error handling for entity operations. - Added `SqlTableHelper` for managing SQL table creation and validation. - Modified `GitHubWebhookEntity` to include new properties for better webhook delivery tracking. - Overall improvements to code robustness and maintainability.
1 parent 6a5c96a commit 0bbac7c

6 files changed

Lines changed: 507 additions & 35 deletions

File tree

src/DevExcelerateApi/Data/GitHubWebhookRepository.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ namespace DevExcelerateApi.Data
1111
public class GitHubWebhookRepository(IStorageContext<GitHubWebhookEntity> storageContext)
1212
: Repository<GitHubWebhookEntity>(storageContext)
1313
{
14-
1514
public Task SaveWebhookAsync(WebhookHeaders headers, WebhookEvent webhookEvent)
1615
{
1716
var webhookEntity = new GitHubWebhookEntity
1817
{
19-
GitHubDelivery = headers?.Delivery!,
18+
Id = headers?.Delivery!,
19+
GitHubHook = headers?.HookId,
20+
GitHubEvent = $"{headers?.Event}.{webhookEvent?.Action}",
21+
SenderLogin = webhookEvent?.Sender?.Login,
2022
Headers = JsonSerializer.Serialize(headers),
2123
Payload = JsonSerializer.Serialize(webhookEvent)
2224
};
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using DevExcelerateApi.Models;
2+
3+
namespace DevExcelerateApi.Data
4+
{
5+
/// <summary>
6+
/// Repository for Issue Request entities.
7+
/// Demonstrates how easy it is to create repositories for new entity types.
8+
/// </summary>
9+
/// <param name="storageContext">The storage context for IssueRequestEntity</param>
10+
public class IssueRequestRepository(IStorageContext<IssueRequestEntity> storageContext)
11+
: Repository<IssueRequestEntity>(storageContext)
12+
{
13+
/// <summary>
14+
/// Saves an issue request with additional business logic.
15+
/// </summary>
16+
/// <param name="repositoryName">The repository name</param>
17+
/// <param name="issueNumber">The issue number</param>
18+
/// <param name="title">The issue title</param>
19+
/// <param name="body">The issue body</param>
20+
/// <param name="createdBy">The user who created the issue</param>
21+
/// <returns>The created issue request entity</returns>
22+
public async Task<IssueRequestEntity> CreateIssueRequestAsync(
23+
string repositoryName,
24+
long issueNumber,
25+
string title,
26+
string? body = null,
27+
string? createdBy = null)
28+
{
29+
var issueRequest = new IssueRequestEntity
30+
{
31+
RepositoryName = repositoryName,
32+
IssueNumber = issueNumber,
33+
Title = title,
34+
Body = body,
35+
CreatedBy = createdBy,
36+
State = "open"
37+
};
38+
39+
await CreateAsync(issueRequest);
40+
return issueRequest;
41+
}
42+
43+
/// <summary>
44+
/// Finds all issue requests for a specific repository.
45+
/// </summary>
46+
/// <param name="repositoryName">The repository name</param>
47+
/// <returns>List of issue requests for the repository</returns>
48+
public async Task<IEnumerable<IssueRequestEntity>> FindByRepositoryAsync(string repositoryName)
49+
{
50+
return await StorageContext.QueryEntitiesAsync(entity =>
51+
entity.RepositoryName?.Equals(repositoryName, StringComparison.OrdinalIgnoreCase) == true);
52+
}
53+
54+
/// <summary>
55+
/// Finds issue requests by state.
56+
/// </summary>
57+
/// <param name="state">The issue state (open, closed, etc.)</param>
58+
/// <returns>List of issue requests with the specified state</returns>
59+
public async Task<IEnumerable<IssueRequestEntity>> FindByStateAsync(string state)
60+
{
61+
return await StorageContext.QueryEntitiesAsync(entity =>
62+
entity.State?.Equals(state, StringComparison.OrdinalIgnoreCase) == true);
63+
}
64+
65+
/// <summary>
66+
/// Updates the state of an issue request.
67+
/// </summary>
68+
/// <param name="issueRequestId">The issue request ID</param>
69+
/// <param name="newState">The new state</param>
70+
/// <param name="partition">The partition key (optional)</param>
71+
public async Task UpdateStateAsync(string issueRequestId, string newState, string? partition = null)
72+
{
73+
var issueRequest = await FindByIdAsync(issueRequestId, partition);
74+
issueRequest.State = newState;
75+
issueRequest.UpdatedOn = DateTimeOffset.UtcNow;
76+
77+
await UpsertAsync(issueRequest);
78+
}
79+
80+
/// <summary>
81+
/// Finds issue requests created within a date range.
82+
/// </summary>
83+
/// <param name="startDate">Start date</param>
84+
/// <param name="endDate">End date</param>
85+
/// <returns>List of issue requests created within the date range</returns>
86+
public async Task<IEnumerable<IssueRequestEntity>> FindByDateRangeAsync(DateTimeOffset startDate, DateTimeOffset endDate)
87+
{
88+
return await StorageContext.QueryEntitiesAsync(entity =>
89+
entity.CreatedOn >= startDate && entity.CreatedOn <= endDate);
90+
}
91+
}
92+
}

src/DevExcelerateApi/Data/SqlDbStorageContext.cs

Lines changed: 117 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,72 +5,160 @@
55

66
namespace DevExcelerateApi.Data;
77

8+
/// <summary>
9+
/// SQL Database storage context that creates entity-specific tables for each IStorageEntity type.
10+
/// Each entity type gets its own table with a consistent schema pattern.
11+
/// </summary>
12+
/// <typeparam name="T">The entity type that implements IStorageEntity</typeparam>
813
public class SqlDbStorageContext<T> : IStorageContext<T>, IDisposable where T : IStorageEntity
914
{
1015
private readonly SqlDbOptions _sqlDbOptions;
16+
private readonly string _tableName;
17+
private bool _disposed = false;
1118

1219
public SqlDbStorageContext(SqlDbOptions sqlDbOptions)
1320
{
14-
_sqlDbOptions = sqlDbOptions ?? throw new ArgumentNullException("SqlDb");
21+
_sqlDbOptions = sqlDbOptions ?? throw new ArgumentNullException(nameof(sqlDbOptions));
22+
_tableName = SqlTableHelper.GetTableName<T>();
23+
24+
if (!SqlTableHelper.IsValidTableName(_tableName))
25+
{
26+
throw new ArgumentException($"Invalid table name generated for entity type '{typeof(T).Name}': '{_tableName}'");
27+
}
1528
}
1629

17-
public Task<IEnumerable<T>> QueryEntitiesAsync(Func<T, bool> predicate)
30+
/// <summary>
31+
/// Creates a new SQL connection and ensures the table exists.
32+
/// </summary>
33+
/// <returns>A configured SQL connection</returns>
34+
private async Task<SqlConnection> CreateConnectionAsync()
1835
{
19-
// could use extensions for dapper at https://github.com/tmsmith/Dapper-Extensions using the GetList Operation (with Predicates). To be tested
20-
throw new NotImplementedException();
36+
var connection = new SqlConnection(_sqlDbOptions.ConnectionString);
37+
await connection.OpenAsync();
38+
await SqlTableHelper.EnsureTableExistsAsync(connection, _tableName);
39+
return connection;
40+
}
41+
42+
public async Task<IEnumerable<T>> QueryEntitiesAsync(Func<T, bool> predicate)
43+
{
44+
using var connection = await CreateConnectionAsync();
45+
46+
var sql = SqlTableHelper.GetQueryAllEntitiesSql(_tableName);
47+
var jsonDataList = await connection.QueryAsync<string>(sql);
48+
49+
var entities = jsonDataList
50+
.Select(jsonData => JsonSerializer.Deserialize<T>(jsonData)!)
51+
.Where(predicate);
52+
53+
return entities;
2154
}
2255

2356
public async Task<T> ReadAsync(string entityId, string partitionKey)
2457
{
25-
using (var connection = new SqlConnection(_sqlDbOptions.ConnectionString))
58+
if (string.IsNullOrWhiteSpace(entityId))
59+
{
60+
throw new ArgumentOutOfRangeException(nameof(entityId), "Entity Id cannot be null or empty.");
61+
}
62+
63+
using var connection = await CreateConnectionAsync();
64+
65+
var sql = SqlTableHelper.GetReadEntitySql(_tableName);
66+
var jsonData = await connection.QueryFirstOrDefaultAsync<string>(sql, new { EntityId = entityId, PartitionKey = partitionKey });
67+
68+
if (jsonData == null)
2669
{
27-
var jsonData = await connection.QueryFirstAsync<string>(
28-
"SELECT EventData FROM [WebhookEvents] WHERE EventId = @Id AND EventType = @EventType",
29-
new { Id = entityId, EventType = typeof(T).Name });
30-
return JsonSerializer.Deserialize<T>(jsonData)!;
70+
throw new KeyNotFoundException($"Entity with id '{entityId}' and partition '{partitionKey}' not found in table '{_tableName}'.");
3171
}
72+
73+
return JsonSerializer.Deserialize<T>(jsonData)!;
3274
}
3375

3476
public async Task CreateAsync(T entity)
3577
{
36-
using (var connection = new SqlConnection(_sqlDbOptions.ConnectionString))
78+
if (string.IsNullOrWhiteSpace(entity.Id))
79+
{
80+
throw new ArgumentOutOfRangeException(nameof(entity), "Entity Id cannot be null or empty.");
81+
}
82+
83+
using var connection = await CreateConnectionAsync();
84+
85+
var sql = SqlTableHelper.GetInsertEntitySql(_tableName);
86+
87+
try
88+
{
89+
await connection.ExecuteAsync(sql, new
90+
{
91+
EntityId = entity.Id,
92+
PartitionKey = entity.Partition,
93+
EntityData = JsonSerializer.Serialize(entity)
94+
});
95+
}
96+
catch (SqlException ex) when (ex.Number == 2627) // Unique constraint violation
3797
{
38-
await connection.ExecuteAsync(
39-
"INSERT INTO [WebhookEvents] ([EventId], [EventType],[EventData]) VALUES (@EventId, @EventType, @EventData)",
40-
new { EventId = entity.Id, EventType = typeof(T).Name, EventData = JsonSerializer.Serialize(entity) });
98+
throw new InvalidOperationException($"Entity with id '{entity.Id}' and partition '{entity.Partition}' already exists in table '{_tableName}'.", ex);
4199
}
42100
}
43101

44102
public async Task UpsertAsync(T entity)
45103
{
46-
using (var connection = new SqlConnection(_sqlDbOptions.ConnectionString))
104+
if (string.IsNullOrWhiteSpace(entity.Id))
47105
{
48-
var exists = await connection.ExecuteScalarAsync<bool>(
49-
"SELECT COUNT(1) FROM [WebhookEvents] WHERE EventId = @Id AND EventType = @EventType",
50-
new { Id = entity.Id, EventType = typeof(T).Name });
51-
if (exists)
52-
{
53-
await connection.ExecuteAsync(
54-
"UPDATE [WebhookEvents] SET EventData = @EventData WHERE EventId = @Id AND EventType = @EventType",
55-
new { Id = entity.Id, EventType = typeof(T).Name, EventData = JsonSerializer.Serialize(entity) });
56-
}
57-
else
106+
throw new ArgumentOutOfRangeException(nameof(entity), "Entity Id cannot be null or empty.");
107+
}
108+
109+
using var connection = await CreateConnectionAsync();
110+
111+
var checkSql = SqlTableHelper.GetEntityExistsSql(_tableName);
112+
var exists = await connection.ExecuteScalarAsync<bool>(checkSql, new { EntityId = entity.Id, PartitionKey = entity.Partition });
113+
114+
if (exists)
115+
{
116+
var updateSql = SqlTableHelper.GetUpdateEntitySql(_tableName);
117+
118+
await connection.ExecuteAsync(updateSql, new
58119
{
59-
await CreateAsync(entity);
60-
}
120+
EntityId = entity.Id,
121+
PartitionKey = entity.Partition,
122+
EntityData = JsonSerializer.Serialize(entity)
123+
});
124+
}
125+
else
126+
{
127+
await CreateAsync(entity);
61128
}
62129
}
63130

64131
public async Task DeleteAsync(T entity)
65132
{
66-
using (var connection = new SqlConnection(_sqlDbOptions.ConnectionString))
133+
if (string.IsNullOrWhiteSpace(entity.Id))
134+
{
135+
throw new ArgumentOutOfRangeException(nameof(entity), "Entity Id cannot be null or empty.");
136+
}
137+
138+
using var connection = await CreateConnectionAsync();
139+
140+
var sql = SqlTableHelper.GetDeleteEntitySql(_tableName);
141+
var rowsAffected = await connection.ExecuteAsync(sql, new { EntityId = entity.Id, PartitionKey = entity.Partition });
142+
143+
if (rowsAffected == 0)
67144
{
68-
await connection.ExecuteAsync("DELETE FROM [WebhookEvents] WHERE EventId = @Id AND EventType = @EventType",
69-
new { Id = entity.Id, EventType = typeof(T).Name });
145+
throw new KeyNotFoundException($"Entity with id '{entity.Id}' and partition '{entity.Partition}' not found in table '{_tableName}'.");
70146
}
71147
}
72148

73149
public void Dispose()
74150
{
151+
Dispose(true);
152+
GC.SuppressFinalize(this);
153+
}
154+
155+
protected virtual void Dispose(bool disposing)
156+
{
157+
if (!_disposed && disposing)
158+
{
159+
// No specific resources to dispose in this implementation
160+
// Connection disposal is handled in the using statements
161+
}
162+
_disposed = true;
75163
}
76164
}

0 commit comments

Comments
 (0)