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 const string QueryExecutedSuccessfullyMessage = "Query executed successfully."; private const string QueryExecutedAndDeployedMessage = "Query executed and deployed successfully"; private const string SqlIdentifierPattern = @"(?:\[[^\]]+\]|\""[^\""\r\n]+\""|[A-Za-z_][A-Za-z0-9_@$#]*)"; private const string MultiPartSqlIdentifierPattern = SqlIdentifierPattern + @"(?:\s*\.\s*" + SqlIdentifierPattern + @"){0,2}"; private static readonly Regex SqlObjectDefinitionRegex = new( @"^\s*(?:(?:--[^\r\n]*|/\*[\s\S]*?\*/)\s*)*(?CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?VIEW|PROC(?:EDURE)?|FUNCTION)\s+(?" + MultiPartSqlIdentifierPattern + @")", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled); private static readonly Regex SqlObjectDropRegex = new( @"^\s*(?:(?:--[^\r\n]*|/\*[\s\S]*?\*/)\s*)*DROP\s+(?VIEW|PROC(?:EDURE)?|FUNCTION)\s+(?:IF\s+EXISTS\s+)?(?" + MultiPartSqlIdentifierPattern + @"(?:\s*,\s*" + MultiPartSqlIdentifierPattern + @")*)", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled); private static readonly Regex SqlObjectHeaderCanonicalizeRegex = new( @"^(?\s*(?:(?:--[^\r\n]*\r?\n|/\*[\s\S]*?\*/)\s*)*)(?CREATE|ALTER)\s+(?(?:OR\s+ALTER\s+)?(?:VIEW|PROC(?:EDURE)?|FUNCTION)\b)", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled); 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(); var isDeployed = false; // 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); if (result.Success) { isDeployed = TrySyncSqlObjectScriptFile(input.QueryText); } return MapExecutionResult(result, isDeployed); } // 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); isDeployed |= TrySyncSqlObjectScriptFile(batch); } return MapExecutionResult(lastResult!, isDeployed); } 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; // Always open object script as CREATE OR ALTER in editor. definition = Regex.Replace( definition, @"^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?", "CREATE OR ALTER ", 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, bool isDeployed = false) { return new SqlQueryExecutionResultDto { Success = result.Success, Message = result.Success ? (isDeployed ? QueryExecutedAndDeployedMessage : QueryExecutedSuccessfullyMessage) : 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; } private bool TrySyncSqlObjectScriptFile(string sqlScript) { if (string.IsNullOrWhiteSpace(sqlScript)) return false; if (TrySaveSqlObjectScript(sqlScript)) return true; return TryDeleteSqlObjectScripts(sqlScript); } private bool TrySaveSqlObjectScript(string sqlScript) { var match = SqlObjectDefinitionRegex.Match(sqlScript); if (!match.Success) return false; var objectType = NormalizeObjectType(match.Groups["type"].Value); var (schemaName, objectName) = ParseSchemaAndObjectName(match.Groups["name"].Value); if (string.IsNullOrWhiteSpace(objectName)) return false; var fileName = BuildSqlObjectScriptFileName(objectType, schemaName, objectName); var canonicalScript = CanonicalizeSqlObjectScriptForSeed(sqlScript); SaveSqlDataFile(fileName, canonicalScript); return true; } private bool TryDeleteSqlObjectScripts(string sqlScript) { var match = SqlObjectDropRegex.Match(sqlScript); if (!match.Success) return false; var objectType = NormalizeObjectType(match.Groups["type"].Value); var rawNames = match.Groups["names"].Value .Split(',') .Select(x => x.Trim()) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var rawName in rawNames) { var (schemaName, objectName) = ParseSchemaAndObjectName(rawName); if (string.IsNullOrWhiteSpace(objectName)) continue; var fileName = BuildSqlObjectScriptFileName(objectType, schemaName, objectName); DeleteSqlDataFile(fileName); } return rawNames.Count > 0; } private void SaveSqlDataFile(string fileName, string content) { try { var outputPath = ResolveSqlDataOutputPath(); Directory.CreateDirectory(outputPath); var safeFileName = string.Concat(fileName.Split(Path.GetInvalidFileNameChars())); if (string.IsNullOrWhiteSpace(safeFileName)) return; var filePath = Path.Combine(outputPath, $"{safeFileName}.sql"); File.WriteAllText(filePath, content ?? string.Empty); _logger.LogInformation("SQL object script saved: {FilePath}", filePath); } catch (Exception ex) { // File save failure does not block query execution _logger.LogError(ex, "Failed to save SQL object script: {Message}", ex.Message); } } private void DeleteSqlDataFile(string fileName) { try { var outputPath = ResolveSqlDataOutputPath(); if (!Directory.Exists(outputPath)) return; var safeFileName = string.Concat(fileName.Split(Path.GetInvalidFileNameChars())); if (string.IsNullOrWhiteSpace(safeFileName)) return; var filePath = Path.Combine(outputPath, $"{safeFileName}.sql"); if (!File.Exists(filePath)) return; File.Delete(filePath); _logger.LogInformation("SQL object script deleted: {FilePath}", filePath); } catch (Exception ex) { // File delete failure does not block query execution _logger.LogError(ex, "Failed to delete SQL object script: {Message}", ex.Message); } } private static string BuildSqlObjectScriptFileName(string objectType, string schemaName, string objectName) { return objectName; } private static string CanonicalizeSqlObjectScriptForSeed(string sqlScript) { if (string.IsNullOrWhiteSpace(sqlScript)) return string.Empty; var canonical = SqlObjectHeaderCanonicalizeRegex.Replace(sqlScript, match => { var prefix = match.Groups["prefix"].Value; var after = match.Groups["after"].Value; after = Regex.Replace(after, @"^OR\s+ALTER\s+", string.Empty, RegexOptions.IgnoreCase); return $"{prefix}CREATE OR ALTER {after}"; }, 1); return canonical; } private static string NormalizeObjectType(string rawType) { if (string.Equals(rawType, "PROC", StringComparison.OrdinalIgnoreCase) || string.Equals(rawType, "PROCEDURE", StringComparison.OrdinalIgnoreCase)) { return "procedure"; } if (string.Equals(rawType, "FUNCTION", StringComparison.OrdinalIgnoreCase)) { return "function"; } return "view"; } private static (string SchemaName, string ObjectName) ParseSchemaAndObjectName(string fullName) { var parts = SplitSqlMultipartIdentifier(fullName) .Select(UnquoteSqlIdentifier) .Where(p => !string.IsNullOrWhiteSpace(p)) .ToList(); if (parts.Count == 0) return ("dbo", string.Empty); if (parts.Count == 1) return ("dbo", parts[0]); return (parts[^2], parts[^1]); } private static List SplitSqlMultipartIdentifier(string value) { var parts = new List(); if (string.IsNullOrWhiteSpace(value)) return parts; var buffer = new System.Text.StringBuilder(); var inBracket = false; var inDoubleQuote = false; foreach (var ch in value) { if (ch == '[' && !inDoubleQuote) { inBracket = true; buffer.Append(ch); continue; } if (ch == ']' && inBracket) { inBracket = false; buffer.Append(ch); continue; } if (ch == '"' && !inBracket) { inDoubleQuote = !inDoubleQuote; buffer.Append(ch); continue; } if (ch == '.' && !inBracket && !inDoubleQuote) { var token = buffer.ToString().Trim(); if (!string.IsNullOrWhiteSpace(token)) parts.Add(token); buffer.Clear(); continue; } buffer.Append(ch); } var lastToken = buffer.ToString().Trim(); if (!string.IsNullOrWhiteSpace(lastToken)) parts.Add(lastToken); return parts; } private static string UnquoteSqlIdentifier(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; value = value.Trim(); if (value.Length >= 2 && value[0] == '[' && value[^1] == ']') return value.Substring(1, value.Length - 2); if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') return value.Substring(1, value.Length - 2); return value; } [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"); } }