385 lines
14 KiB
C#
385 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Sozsoft.SqlQueryManager.Application.Contracts;
|
|
using Sozsoft.SqlQueryManager.Domain.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Volo.Abp.Application.Services;
|
|
using Volo.Abp.MultiTenancy;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Sozsoft.SqlQueryManager.Application;
|
|
|
|
/// <summary>
|
|
/// Executes T-SQL against configured data sources and exposes database metadata.
|
|
/// Does not persist SQL objects (queries, procedures, views, functions) to its own tables.
|
|
/// </summary>
|
|
[Authorize("App.SqlQueryManager")]
|
|
public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerAppService
|
|
{
|
|
private readonly ISqlExecutorService _sqlExecutorService;
|
|
private readonly ISqlTemplateProvider _templateProvider;
|
|
private readonly ICurrentTenant _currentTenant;
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
private readonly IHostEnvironment _hostEnvironment;
|
|
private readonly ILogger<SqlObjectManagerAppService> _logger;
|
|
|
|
public SqlObjectManagerAppService(
|
|
ISqlExecutorService sqlExecutorService,
|
|
ISqlTemplateProvider templateProvider,
|
|
ICurrentTenant currentTenant,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IHostEnvironment hostEnvironment,
|
|
ILogger<SqlObjectManagerAppService> logger)
|
|
{
|
|
_sqlExecutorService = sqlExecutorService;
|
|
_templateProvider = templateProvider;
|
|
_currentTenant = currentTenant;
|
|
_httpContextAccessor = httpContextAccessor;
|
|
_hostEnvironment = hostEnvironment;
|
|
_logger = logger;
|
|
}
|
|
|
|
private string GetTenantFromHeader()
|
|
{
|
|
return _httpContextAccessor.HttpContext?
|
|
.Request?
|
|
.Headers["__tenant"]
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
private void ValidateTenantAccess()
|
|
{
|
|
var headerTenant = GetTenantFromHeader();
|
|
var currentTenantName = _currentTenant.Name;
|
|
|
|
if (_currentTenant.IsAvailable)
|
|
{
|
|
if (headerTenant != currentTenantName)
|
|
{
|
|
throw new Volo.Abp.UserFriendlyException($"Tenant mismatch. Header tenant '{headerTenant}' does not match current tenant '{currentTenantName}'.");
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<SqlObjectExplorerDto> GetAllObjectsAsync(string dataSourceCode)
|
|
{
|
|
ValidateTenantAccess();
|
|
var result = new SqlObjectExplorerDto();
|
|
|
|
result.Tables = await GetTablesAsync(dataSourceCode);
|
|
result.Views = await GetNativeObjectsAsync(dataSourceCode, "V");
|
|
result.StoredProcedures = await GetNativeObjectsAsync(dataSourceCode, "P");
|
|
result.Functions = await GetNativeObjectsAsync(dataSourceCode, "FN", "IF", "TF");
|
|
|
|
result.Templates = _templateProvider.GetAvailableQueryTemplates()
|
|
.Select(t => new SqlTemplateDto
|
|
{
|
|
Type = t.Type,
|
|
Name = t.Name,
|
|
Description = t.Description,
|
|
Template = _templateProvider.GetQueryTemplate(t.Type)
|
|
})
|
|
.ToList();
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task<List<SqlNativeObjectDto>> GetNativeObjectsAsync(string dataSourceCode, params string[] objectTypes)
|
|
{
|
|
var typeList = string.Join(",", objectTypes.Select(t => $"'{t}'"));
|
|
var query = $@"
|
|
SELECT
|
|
SCHEMA_NAME(o.schema_id) AS SchemaName,
|
|
o.name AS ObjectName
|
|
FROM
|
|
sys.objects o
|
|
WHERE
|
|
o.type IN ({typeList})
|
|
AND o.is_ms_shipped = 0
|
|
ORDER BY
|
|
SCHEMA_NAME(o.schema_id), o.name";
|
|
|
|
var result = await _sqlExecutorService.ExecuteQueryAsync(query, dataSourceCode);
|
|
|
|
var objects = new List<SqlNativeObjectDto>();
|
|
if (result.Success && result.Data != null)
|
|
{
|
|
foreach (var row in result.Data)
|
|
{
|
|
var dict = row as IDictionary<string, object>;
|
|
if (dict != null)
|
|
{
|
|
objects.Add(new SqlNativeObjectDto
|
|
{
|
|
SchemaName = dict["SchemaName"]?.ToString() ?? "dbo",
|
|
ObjectName = dict["ObjectName"]?.ToString() ?? ""
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return objects;
|
|
}
|
|
|
|
private async Task<List<DatabaseTableDto>> GetTablesAsync(string dataSourceCode)
|
|
{
|
|
var query = @"
|
|
SELECT
|
|
SCHEMA_NAME(t.schema_id) AS SchemaName,
|
|
t.name AS TableName
|
|
FROM
|
|
sys.tables t
|
|
WHERE
|
|
t.is_ms_shipped = 0
|
|
ORDER BY
|
|
SCHEMA_NAME(t.schema_id), t.name";
|
|
|
|
var result = await _sqlExecutorService.ExecuteQueryAsync(query, dataSourceCode);
|
|
|
|
var tables = new List<DatabaseTableDto>();
|
|
if (result.Success && result.Data != null)
|
|
{
|
|
foreach (var row in result.Data)
|
|
{
|
|
var dict = row as IDictionary<string, object>;
|
|
if (dict != null)
|
|
{
|
|
tables.Add(new DatabaseTableDto
|
|
{
|
|
SchemaName = dict["SchemaName"]?.ToString() ?? "dbo",
|
|
TableName = dict["TableName"]?.ToString() ?? ""
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return tables;
|
|
}
|
|
|
|
public async Task<SqlQueryExecutionResultDto> ExecuteQueryAsync(ExecuteSqlQueryDto input)
|
|
{
|
|
ValidateTenantAccess();
|
|
|
|
// Split on GO batch separators (SQL Server SSMS convention — not valid T-SQL)
|
|
var batches = Regex.Split(input.QueryText ?? string.Empty, @"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase)
|
|
.Select(b => b.Trim())
|
|
.Where(b => !string.IsNullOrWhiteSpace(b))
|
|
.ToList();
|
|
|
|
if (batches.Count <= 1)
|
|
{
|
|
// Single batch — original path
|
|
var result = await _sqlExecutorService.ExecuteQueryAsync(
|
|
input.QueryText,
|
|
input.DataSourceCode,
|
|
input.Parameters);
|
|
return MapExecutionResult(result);
|
|
}
|
|
|
|
// Multiple batches — execute sequentially, return last meaningful result
|
|
SqlExecutionResult lastResult = null;
|
|
foreach (var batch in batches)
|
|
{
|
|
lastResult = await _sqlExecutorService.ExecuteQueryAsync(
|
|
batch,
|
|
input.DataSourceCode,
|
|
input.Parameters);
|
|
|
|
if (!lastResult.Success)
|
|
return MapExecutionResult(lastResult);
|
|
}
|
|
|
|
return MapExecutionResult(lastResult!);
|
|
}
|
|
|
|
public async Task<string> GetNativeObjectDefinitionAsync(string dataSourceCode, string schemaName, string objectName)
|
|
{
|
|
ValidateTenantAccess();
|
|
var query = @"
|
|
SELECT OBJECT_DEFINITION(OBJECT_ID(@ObjectName)) AS Definition";
|
|
|
|
var fullObjectName = $"[{schemaName}].[{objectName}]";
|
|
var result = await _sqlExecutorService.ExecuteQueryAsync(
|
|
query.Replace("@ObjectName", $"'{fullObjectName}'"),
|
|
dataSourceCode);
|
|
|
|
if (result.Success && result.Data != null)
|
|
{
|
|
var dataList = result.Data.ToList();
|
|
if (dataList.Count > 0)
|
|
{
|
|
var row = dataList[0] as IDictionary<string, object>;
|
|
if (row != null && row.ContainsKey("Definition"))
|
|
{
|
|
var definition = row["Definition"]?.ToString() ?? string.Empty;
|
|
// Replace first CREATE keyword with ALTER so the user edits, not recreates
|
|
definition = System.Text.RegularExpressions.Regex.Replace(
|
|
definition,
|
|
@"^\s*CREATE\s+",
|
|
"ALTER ",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
return definition;
|
|
}
|
|
}
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
public async Task<List<DatabaseColumnDto>> GetTableColumnsAsync(string dataSourceCode, string schemaName, string tableName)
|
|
{
|
|
ValidateTenantAccess();
|
|
var query = $@"
|
|
SELECT
|
|
c.name AS ColumnName,
|
|
TYPE_NAME(c.user_type_id) AS DataType,
|
|
c.is_nullable AS IsNullable,
|
|
c.max_length AS MaxLength
|
|
FROM
|
|
sys.columns c
|
|
INNER JOIN sys.tables t ON c.object_id = t.object_id
|
|
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
|
|
WHERE
|
|
s.name = '{schemaName}'
|
|
AND t.name = '{tableName}'
|
|
ORDER BY
|
|
c.column_id";
|
|
|
|
var result = await _sqlExecutorService.ExecuteQueryAsync(query, dataSourceCode);
|
|
|
|
var columns = new List<DatabaseColumnDto>();
|
|
if (result.Success && result.Data != null)
|
|
{
|
|
foreach (var row in result.Data)
|
|
{
|
|
var dict = row as IDictionary<string, object>;
|
|
if (dict != null)
|
|
{
|
|
columns.Add(new DatabaseColumnDto
|
|
{
|
|
ColumnName = dict["ColumnName"]?.ToString() ?? "",
|
|
DataType = dict["DataType"]?.ToString() ?? "",
|
|
IsNullable = dict["IsNullable"] is bool b && b,
|
|
MaxLength = dict["MaxLength"] != null ? int.Parse(dict["MaxLength"].ToString()) : null
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return columns;
|
|
}
|
|
|
|
private SqlQueryExecutionResultDto MapExecutionResult(SqlExecutionResult result)
|
|
{
|
|
return new SqlQueryExecutionResultDto
|
|
{
|
|
Success = result.Success,
|
|
Message = result.Message,
|
|
Data = result.Data,
|
|
RowsAffected = result.RowsAffected,
|
|
ExecutionTimeMs = result.ExecutionTimeMs,
|
|
Metadata = result.Metadata
|
|
};
|
|
}
|
|
|
|
public Task SaveTableScriptAsync(SaveTableScriptDto input)
|
|
{
|
|
// Security: reject any path traversal attempts
|
|
if (string.IsNullOrWhiteSpace(input?.FileName) ||
|
|
input.FileName.Contains('/') ||
|
|
input.FileName.Contains('\\') ||
|
|
input.FileName.Contains(".."))
|
|
{
|
|
throw new Volo.Abp.UserFriendlyException("Invalid file name.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var outputPath = ResolveSqlDataOutputPath();
|
|
Directory.CreateDirectory(outputPath);
|
|
|
|
var safeFileName = string.Concat(input.FileName.Trim().Split(Path.GetInvalidFileNameChars()));
|
|
var filePath = Path.Combine(outputPath, $"{safeFileName}.sql");
|
|
File.WriteAllText(filePath, input.SqlScript);
|
|
|
|
_logger.LogInformation("SQL seed file saved: {FilePath}", filePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// File save failure does not block the deploy
|
|
_logger.LogError(ex, "Failed to save SQL seed file: {Message}", ex.Message);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
[HttpPost("api/app/sql-object-manager/delete-sql-data-files")]
|
|
public Task DeleteSqlDataFilesAsync(DeleteSqlDataFilesDto input)
|
|
{
|
|
if (input?.FileNames == null || input.FileNames.Count == 0)
|
|
return Task.CompletedTask;
|
|
|
|
try
|
|
{
|
|
var outputPath = ResolveSqlDataOutputPath();
|
|
if (!Directory.Exists(outputPath))
|
|
return Task.CompletedTask;
|
|
|
|
foreach (var rawName in input.FileNames.Distinct())
|
|
{
|
|
if (string.IsNullOrWhiteSpace(rawName))
|
|
continue;
|
|
|
|
// Security: reject any path traversal attempts
|
|
if (rawName.Contains('/') || rawName.Contains('\\') || rawName.Contains(".."))
|
|
continue;
|
|
|
|
var safeFileName = string.Concat(rawName.Trim().Split(Path.GetInvalidFileNameChars()));
|
|
if (string.IsNullOrWhiteSpace(safeFileName))
|
|
continue;
|
|
|
|
var filePath = Path.Combine(outputPath, $"{safeFileName}.sql");
|
|
if (!File.Exists(filePath))
|
|
continue;
|
|
|
|
File.Delete(filePath);
|
|
_logger.LogInformation("SQL seed file deleted: {FilePath}", filePath);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// File delete failure does not block drop operation
|
|
_logger.LogError(ex, "Failed to delete SQL seed file(s): {Message}", ex.Message);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private string ResolveSqlDataOutputPath()
|
|
{
|
|
const string dbMigratorName = "Sozsoft.Platform.DbMigrator";
|
|
var dir = new DirectoryInfo(_hostEnvironment.ContentRootPath);
|
|
|
|
while (dir != null)
|
|
{
|
|
var candidate = Path.Combine(dir.FullName, "src", dbMigratorName, "Seeds");
|
|
if (Directory.Exists(candidate))
|
|
return Path.Combine(candidate, "SqlData");
|
|
|
|
candidate = Path.Combine(dir.FullName, dbMigratorName, "Seeds");
|
|
if (Directory.Exists(candidate))
|
|
return Path.Combine(candidate, "SqlData");
|
|
|
|
dir = dir.Parent;
|
|
}
|
|
|
|
return Path.Combine(_hostEnvironment.ContentRootPath, "Seeds", "SqlData");
|
|
}
|
|
}
|