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; /// /// Executes T-SQL against configured data sources and exposes database metadata. /// Does not persist SQL objects (queries, procedures, views, functions) to its own tables. /// [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 _logger; public SqlObjectManagerAppService( ISqlExecutorService sqlExecutorService, ISqlTemplateProvider templateProvider, ICurrentTenant currentTenant, IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment, ILogger 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 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> 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(); if (result.Success && result.Data != null) { foreach (var row in result.Data) { var dict = row as IDictionary; if (dict != null) { objects.Add(new SqlNativeObjectDto { SchemaName = dict["SchemaName"]?.ToString() ?? "dbo", ObjectName = dict["ObjectName"]?.ToString() ?? "" }); } } } return objects; } private async Task> 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(); if (result.Success && result.Data != null) { foreach (var row in result.Data) { var dict = row as IDictionary; if (dict != null) { tables.Add(new DatabaseTableDto { SchemaName = dict["SchemaName"]?.ToString() ?? "dbo", TableName = dict["TableName"]?.ToString() ?? "" }); } } } return tables; } public async Task 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 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; 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> 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(); if (result.Success && result.Data != null) { foreach (var row in result.Data) { var dict = row as IDictionary; 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"); } }