Skip to content

Commit 1d97f0b

Browse files
feat(ef-core): migrate from Dapper to Entity Framework Core
Replace Dapper raw SQL with EF Core LINQ queries for type-safe, multi-provider database access (SQL Server, MySQL, PostgreSQL). - Add 6 entity models mapping to existing database tables - Create OrderMonitorDbContext with Fluent API configuration - Implement EfCoreOrderRepository with LINQ-based stuck order queries - Add DatabaseProviderFactory for multi-provider DbContext creation - Extract IDiagnosticsService interface for schema discovery - Create DiagnosticsService using EF Core model metadata - Update DI registration to use EF Core instead of Dapper - Remove Dapper, Microsoft.Data.SqlClient dependencies - Add EF Core packages: SqlServer 10.0.3, MySQL 10.0.1, Npgsql 10.0.0 - Add 50 unit tests (entities, DbContext, repository, provider factory) Closes BD-847 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7abe939 commit 1d97f0b

24 files changed

Lines changed: 1468 additions & 377 deletions

src/OrderMonitor.Api/Controllers/DiagnosticsController.cs

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
using Dapper;
21
using Microsoft.AspNetCore.Mvc;
32
using OrderMonitor.Core.Interfaces;
43

54
namespace OrderMonitor.Api.Controllers;
65

76
/// <summary>
87
/// Diagnostic endpoints for debugging and schema discovery.
8+
/// Uses EF Core metadata instead of raw INFORMATION_SCHEMA queries.
99
/// </summary>
1010
[ApiController]
1111
[Route("api/[controller]")]
1212
[Produces("application/json")]
1313
public class DiagnosticsController : ControllerBase
1414
{
15-
private readonly IDbConnectionFactory _connectionFactory;
15+
private readonly IDiagnosticsService _diagnosticsService;
1616
private readonly ILogger<DiagnosticsController> _logger;
1717

18-
public DiagnosticsController(IDbConnectionFactory connectionFactory, ILogger<DiagnosticsController> logger)
18+
public DiagnosticsController(IDiagnosticsService diagnosticsService, ILogger<DiagnosticsController> logger)
1919
{
20-
_connectionFactory = connectionFactory;
20+
_diagnosticsService = diagnosticsService;
2121
_logger = logger;
2222
}
2323

@@ -29,13 +29,12 @@ public async Task<IActionResult> GetOrderTables()
2929
{
3030
try
3131
{
32-
using var conn = _connectionFactory.CreateConnection();
33-
var tables = await conn.QueryAsync<string>(
34-
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME LIKE '%Order%' ORDER BY TABLE_NAME");
32+
var tables = await _diagnosticsService.GetOrderTablesAsync();
3533
return Ok(tables);
3634
}
3735
catch (Exception ex)
3836
{
37+
_logger.LogError(ex, "Error retrieving order tables");
3938
return StatusCode(500, new { error = ex.Message });
4039
}
4140
}
@@ -48,17 +47,12 @@ public async Task<IActionResult> GetTableColumns(string tableName)
4847
{
4948
try
5049
{
51-
using var conn = _connectionFactory.CreateConnection();
52-
var columns = await conn.QueryAsync<dynamic>(
53-
@"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
54-
FROM INFORMATION_SCHEMA.COLUMNS
55-
WHERE TABLE_NAME = @TableName
56-
ORDER BY ORDINAL_POSITION",
57-
new { TableName = tableName });
50+
var columns = await _diagnosticsService.GetTableColumnsAsync(tableName);
5851
return Ok(columns);
5952
}
6053
catch (Exception ex)
6154
{
55+
_logger.LogError(ex, "Error retrieving columns for table {TableName}", tableName);
6256
return StatusCode(500, new { error = ex.Message });
6357
}
6458
}
@@ -78,12 +72,12 @@ public async Task<IActionResult> RunQuery([FromBody] QueryRequest request)
7872

7973
try
8074
{
81-
using var conn = _connectionFactory.CreateConnection();
82-
var results = await conn.QueryAsync<dynamic>(request.Sql);
83-
return Ok(results.Take(100)); // Limit to 100 rows
75+
var results = await _diagnosticsService.RunSelectQueryAsync(request.Sql);
76+
return Ok(results);
8477
}
8578
catch (Exception ex)
8679
{
80+
_logger.LogError(ex, "Error running diagnostic query");
8781
return StatusCode(500, new { error = ex.Message });
8882
}
8983
}

src/OrderMonitor.Core/Interfaces/IDbConnectionFactory.cs

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace OrderMonitor.Core.Interfaces;
2+
3+
/// <summary>
4+
/// Service interface for database diagnostics and schema discovery.
5+
/// </summary>
6+
public interface IDiagnosticsService
7+
{
8+
/// <summary>
9+
/// Gets table names containing 'Order' from the database model.
10+
/// </summary>
11+
Task<IEnumerable<string>> GetOrderTablesAsync();
12+
13+
/// <summary>
14+
/// Gets column information for a specific table.
15+
/// </summary>
16+
Task<IEnumerable<ColumnInfo>> GetTableColumnsAsync(string tableName);
17+
18+
/// <summary>
19+
/// Runs a SELECT-only SQL query and returns results.
20+
/// </summary>
21+
Task<IEnumerable<IDictionary<string, object?>>> RunSelectQueryAsync(string sql);
22+
}
23+
24+
/// <summary>
25+
/// Represents column metadata for a database table.
26+
/// </summary>
27+
public class ColumnInfo
28+
{
29+
public string ColumnName { get; set; } = string.Empty;
30+
public string DataType { get; set; } = string.Empty;
31+
public string IsNullable { get; set; } = string.Empty;
32+
public int? CharacterMaximumLength { get; set; }
33+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace OrderMonitor.Infrastructure.Data;
4+
5+
/// <summary>
6+
/// Factory for creating DbContextOptions configured for the specified database provider.
7+
/// Supports SQL Server, MySQL, and PostgreSQL.
8+
/// </summary>
9+
public static class DatabaseProviderFactory
10+
{
11+
/// <summary>
12+
/// Creates DbContextOptions for the specified provider and connection string.
13+
/// </summary>
14+
/// <param name="provider">Database provider: "sqlserver", "mysql", or "postgresql"</param>
15+
/// <param name="connectionString">The database connection string</param>
16+
/// <returns>Configured DbContextOptions</returns>
17+
public static DbContextOptions<OrderMonitorDbContext> CreateOptions(
18+
string provider,
19+
string connectionString)
20+
{
21+
if (string.IsNullOrWhiteSpace(connectionString))
22+
throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString));
23+
24+
if (string.IsNullOrWhiteSpace(provider))
25+
throw new ArgumentException("Provider cannot be null or empty.", nameof(provider));
26+
27+
var builder = new DbContextOptionsBuilder<OrderMonitorDbContext>();
28+
29+
switch (provider.ToLowerInvariant())
30+
{
31+
case "sqlserver":
32+
builder.UseSqlServer(connectionString);
33+
break;
34+
case "mysql":
35+
builder.UseMySQL(connectionString);
36+
break;
37+
case "postgresql":
38+
case "postgres":
39+
builder.UseNpgsql(connectionString);
40+
break;
41+
default:
42+
throw new ArgumentException(
43+
$"Unsupported database provider: '{provider}'. Supported providers: sqlserver, mysql, postgresql.",
44+
nameof(provider));
45+
}
46+
47+
return builder.Options;
48+
}
49+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using OrderMonitor.Core.Interfaces;
3+
4+
namespace OrderMonitor.Infrastructure.Data;
5+
6+
/// <summary>
7+
/// EF Core-based diagnostics service.
8+
/// Uses model metadata for schema discovery instead of INFORMATION_SCHEMA queries.
9+
/// </summary>
10+
public class DiagnosticsService : IDiagnosticsService
11+
{
12+
private readonly OrderMonitorDbContext _context;
13+
14+
public DiagnosticsService(OrderMonitorDbContext context)
15+
{
16+
_context = context ?? throw new ArgumentNullException(nameof(context));
17+
}
18+
19+
/// <inheritdoc />
20+
public Task<IEnumerable<string>> GetOrderTablesAsync()
21+
{
22+
var entityTypes = _context.Model.GetEntityTypes()
23+
.Select(e => e.GetTableName())
24+
.Where(t => t != null && t.Contains("Order", StringComparison.OrdinalIgnoreCase))
25+
.Cast<string>()
26+
.OrderBy(t => t)
27+
.AsEnumerable();
28+
29+
return Task.FromResult(entityTypes);
30+
}
31+
32+
/// <inheritdoc />
33+
public Task<IEnumerable<ColumnInfo>> GetTableColumnsAsync(string tableName)
34+
{
35+
var entityType = _context.Model.GetEntityTypes()
36+
.FirstOrDefault(e => string.Equals(e.GetTableName(), tableName, StringComparison.OrdinalIgnoreCase));
37+
38+
if (entityType == null)
39+
return Task.FromResult(Enumerable.Empty<ColumnInfo>());
40+
41+
var columns = entityType.GetProperties()
42+
.Select(p =>
43+
{
44+
var column = p.GetColumnName();
45+
var clrType = p.ClrType;
46+
var isNullable = p.IsNullable ? "YES" : "NO";
47+
var maxLength = p.GetMaxLength();
48+
49+
return new ColumnInfo
50+
{
51+
ColumnName = column ?? p.Name,
52+
DataType = MapClrTypeToSqlType(clrType),
53+
IsNullable = isNullable,
54+
CharacterMaximumLength = maxLength
55+
};
56+
})
57+
.OrderBy(c => c.ColumnName)
58+
.AsEnumerable();
59+
60+
return Task.FromResult(columns);
61+
}
62+
63+
/// <inheritdoc />
64+
public async Task<IEnumerable<IDictionary<string, object?>>> RunSelectQueryAsync(string sql)
65+
{
66+
var results = new List<IDictionary<string, object?>>();
67+
68+
var connection = _context.Database.GetDbConnection();
69+
await connection.OpenAsync();
70+
71+
try
72+
{
73+
using var command = connection.CreateCommand();
74+
command.CommandText = sql;
75+
76+
using var reader = await command.ExecuteReaderAsync();
77+
var count = 0;
78+
while (await reader.ReadAsync() && count < 100)
79+
{
80+
var row = new Dictionary<string, object?>();
81+
for (int i = 0; i < reader.FieldCount; i++)
82+
{
83+
row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
84+
}
85+
results.Add(row);
86+
count++;
87+
}
88+
}
89+
finally
90+
{
91+
if (connection.State == System.Data.ConnectionState.Open)
92+
await connection.CloseAsync();
93+
}
94+
95+
return results;
96+
}
97+
98+
private static string MapClrTypeToSqlType(Type clrType)
99+
{
100+
var underlying = Nullable.GetUnderlyingType(clrType) ?? clrType;
101+
102+
return underlying.Name switch
103+
{
104+
"String" => "nvarchar",
105+
"Int32" => "int",
106+
"Int64" => "bigint",
107+
"Boolean" => "bit",
108+
"DateTime" => "datetime2",
109+
"Decimal" => "decimal",
110+
"Double" => "float",
111+
"Single" => "real",
112+
"Guid" => "uniqueidentifier",
113+
"Byte[]" => "varbinary",
114+
_ => underlying.Name.ToLower()
115+
};
116+
}
117+
}

0 commit comments

Comments
 (0)