feat(web): add DB Table Management admin page supporting dynamic view and CRUD row modification
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
|
||||
namespace QuantEngine.Web.Pages.Admin.Database
|
||||
{
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<IndexModel> _logger;
|
||||
|
||||
public List<string> TableList { get; set; } = new();
|
||||
public string? SelectedTable { get; set; }
|
||||
public List<string> ColumnNames { get; set; } = new();
|
||||
public List<Dictionary<string, object>> Rows { get; set; } = new();
|
||||
public string? PrimaryKeyColumn { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
public string? ActionTableName { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
public string? ActionRowKey { get; set; }
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
public IndexModel(IDbConnectionFactory connectionFactory, ILogger<IndexModel> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task OnGetAsync(string? tableName)
|
||||
{
|
||||
await LoadTableListAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(tableName))
|
||||
{
|
||||
// Validate table name is in whitelist to prevent SQL Injection
|
||||
if (TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
SelectedTable = tableName;
|
||||
await LoadTableDataAsync(tableName);
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = "허용되지 않은 테이블명입니다.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSaveRowAsync()
|
||||
{
|
||||
await LoadTableListAsync();
|
||||
|
||||
var tableName = Request.Form["tableName"].ToString();
|
||||
var pkColumn = Request.Form["pkColumn"].ToString();
|
||||
var pkValue = Request.Form["pkValue"].ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(tableName) || !TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ErrorMessage = "유효하지 않은 테이블입니다.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
if (conn.State != ConnectionState.Open) conn.Open();
|
||||
|
||||
// Load target columns to update
|
||||
var columns = new List<string>();
|
||||
var parameters = new List<NpgsqlParameter>();
|
||||
|
||||
foreach (var key in Request.Form.Keys)
|
||||
{
|
||||
if (key == "tableName" || key == "pkColumn" || key == "pkValue" || key == "__RequestVerificationToken")
|
||||
continue;
|
||||
|
||||
var val = Request.Form[key].ToString();
|
||||
columns.Add($"\"{key}\" = @{key}");
|
||||
|
||||
var param = new NpgsqlParameter($"@{key}", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
param.Value = (object?)val ?? DBNull.Value;
|
||||
parameters.Add(param);
|
||||
}
|
||||
|
||||
if (columns.Count > 0 && !string.IsNullOrEmpty(pkColumn))
|
||||
{
|
||||
var sql = $"UPDATE {tableName} SET {string.Join(", ", columns)} WHERE \"{pkColumn}\" = @pk_val";
|
||||
|
||||
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||
foreach (var p in parameters) cmd.Parameters.Add(p);
|
||||
|
||||
var pkParam = new NpgsqlParameter("@pk_val", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
pkParam.Value = pkValue;
|
||||
cmd.Parameters.Add(pkParam);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
SuccessMessage = "행 데이터가 성공적으로 수정되었습니다.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update row for {Table}", tableName);
|
||||
ErrorMessage = $"저장 실패: {ex.Message}";
|
||||
}
|
||||
|
||||
return RedirectToPage(new { tableName });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAddRowAsync()
|
||||
{
|
||||
await LoadTableListAsync();
|
||||
|
||||
var tableName = Request.Form["tableName"].ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(tableName) || !TableList.Contains(tableName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ErrorMessage = "유효하지 않은 테이블입니다.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
if (conn.State != ConnectionState.Open) conn.Open();
|
||||
|
||||
var colNames = new List<string>();
|
||||
var paramNames = new List<string>();
|
||||
var parameters = new List<NpgsqlParameter>();
|
||||
|
||||
foreach (var key in Request.Form.Keys)
|
||||
{
|
||||
if (key == "tableName" || key == "__RequestVerificationToken")
|
||||
continue;
|
||||
|
||||
var val = Request.Form[key].ToString();
|
||||
colNames.Add($"\"{key}\"");
|
||||
paramNames.Add($"@{key}");
|
||||
|
||||
var param = new NpgsqlParameter($"@{key}", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
param.Value = (object?)val ?? DBNull.Value;
|
||||
parameters.Add(param);
|
||||
}
|
||||
|
||||
if (colNames.Count > 0)
|
||||
{
|
||||
var sql = $"INSERT INTO {tableName} ({string.Join(", ", colNames)}) VALUES ({string.Join(", ", paramNames)})";
|
||||
|
||||
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||
foreach (var p in parameters) cmd.Parameters.Add(p);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
SuccessMessage = "새 데이터 행이 성공적으로 추가되었습니다.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to insert row for {Table}", tableName);
|
||||
ErrorMessage = $"추가 실패: {ex.Message}";
|
||||
}
|
||||
|
||||
return RedirectToPage(new { tableName });
|
||||
}
|
||||
|
||||
private async Task LoadTableListAsync()
|
||||
{
|
||||
TableList.Clear();
|
||||
try
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
if (conn.State != ConnectionState.Open) conn.Open();
|
||||
|
||||
var sql = @"
|
||||
SELECT table_schema || '.' || table_name AS full_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema IN ('quantengine', 'engine_history')
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_schema, table_name;";
|
||||
|
||||
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
TableList.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load database table list.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadTableDataAsync(string tableName)
|
||||
{
|
||||
ColumnNames.Clear();
|
||||
Rows.Clear();
|
||||
PrimaryKeyColumn = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
if (conn.State != ConnectionState.Open) conn.Open();
|
||||
|
||||
var parts = tableName.Split('.');
|
||||
var schema = parts[0];
|
||||
var tableOnly = parts[1];
|
||||
|
||||
var pkSql = @"
|
||||
SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = @table_name::regclass
|
||||
AND i.indisprimary;";
|
||||
|
||||
using (var pkCmd = new NpgsqlCommand(pkSql, (NpgsqlConnection)conn))
|
||||
{
|
||||
pkCmd.Parameters.AddWithValue("@table_name", tableName);
|
||||
try
|
||||
{
|
||||
var pkResult = await pkCmd.ExecuteScalarAsync();
|
||||
if (pkResult != null) PrimaryKeyColumn = pkResult.ToString();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(PrimaryKeyColumn))
|
||||
{
|
||||
if (tableName.Contains("settings")) PrimaryKeyColumn = "key";
|
||||
else if (tableName.Contains("workspace_account")) PrimaryKeyColumn = "username";
|
||||
else if (tableName.Contains("collection_runs")) PrimaryKeyColumn = "run_id";
|
||||
else if (tableName.Contains("workspace_meta")) PrimaryKeyColumn = "key";
|
||||
}
|
||||
|
||||
var dataSql = $"SELECT * FROM {tableName} LIMIT 100;";
|
||||
using var cmd = new NpgsqlCommand(dataSql, (NpgsqlConnection)conn);
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
|
||||
for (int i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
ColumnNames.Add(reader.GetName(i));
|
||||
}
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var row = new Dictionary<string, object>();
|
||||
for (int i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
var val = reader.GetValue(i);
|
||||
row[reader.GetName(i)] = val == DBNull.Value ? "null" : val;
|
||||
}
|
||||
Rows.Add(row);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load table data for {Table}", tableName);
|
||||
ErrorMessage = $"테이블 데이터 조회 실패: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user