From 953b4e7d9822d25144d7c5184b5206f557d3c9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Mon, 4 May 2026 00:17:11 +0300 Subject: [PATCH] =?UTF-8?q?SqlManagerda=20View,=20Function=20ve=20Sp=20Dep?= =?UTF-8?q?loy=20=C3=B6zelli=C4=9Fi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SqlObjectManagerAppService.cs | 272 +++++++++++++++++- ui/src/views/developerKit/SqlQueryManager.tsx | 4 +- 2 files changed, 265 insertions(+), 11 deletions(-) diff --git a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs index afa4112..401072e 100644 --- a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs +++ b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs @@ -23,6 +23,24 @@ namespace Sozsoft.SqlQueryManager.Application; [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; @@ -166,6 +184,7 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA 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) @@ -180,7 +199,13 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA input.QueryText, input.DataSourceCode, input.Parameters); - return MapExecutionResult(result); + + if (result.Success) + { + isDeployed = TrySyncSqlObjectScriptFile(input.QueryText); + } + + return MapExecutionResult(result, isDeployed); } // Multiple batches — execute sequentially, return last meaningful result @@ -194,9 +219,11 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA if (!lastResult.Success) return MapExecutionResult(lastResult); + + isDeployed |= TrySyncSqlObjectScriptFile(batch); } - return MapExecutionResult(lastResult!); + return MapExecutionResult(lastResult!, isDeployed); } public async Task GetNativeObjectDefinitionAsync(string dataSourceCode, string schemaName, string objectName) @@ -219,12 +246,12 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA 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( + // Always open object script as CREATE OR ALTER in editor. + definition = Regex.Replace( definition, - @"^\s*CREATE\s+", - "ALTER ", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); + @"^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?", + "CREATE OR ALTER ", + RegexOptions.IgnoreCase); return definition; } } @@ -276,12 +303,14 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA return columns; } - private SqlQueryExecutionResultDto MapExecutionResult(SqlExecutionResult result) + private SqlQueryExecutionResultDto MapExecutionResult(SqlExecutionResult result, bool isDeployed = false) { return new SqlQueryExecutionResultDto { Success = result.Success, - Message = result.Message, + Message = result.Success + ? (isDeployed ? QueryExecutedAndDeployedMessage : QueryExecutedSuccessfullyMessage) + : result.Message, Data = result.Data, RowsAffected = result.RowsAffected, ExecutionTimeMs = result.ExecutionTimeMs, @@ -320,6 +349,231 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA 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) { diff --git a/ui/src/views/developerKit/SqlQueryManager.tsx b/ui/src/views/developerKit/SqlQueryManager.tsx index 2170eff..1e1ae9e 100644 --- a/ui/src/views/developerKit/SqlQueryManager.tsx +++ b/ui/src/views/developerKit/SqlQueryManager.tsx @@ -240,7 +240,7 @@ SELECT const normalizeNativeDefinitionToCreate = (definition: string) => { if (!definition?.trim()) return '' - return definition.replace(/^\s*ALTER\s+/i, 'CREATE ') + return definition.replace(/^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?/i, 'CREATE OR ALTER ') } const buildDropIfExistsScript = (obj: SqlExplorerSelectedObject) => { @@ -558,7 +558,7 @@ GO`, objectName, ) if (result.data) { - const definition = result.data.replace(/\bCREATE\b/i, 'ALTER') + const definition = normalizeNativeDefinitionToCreate(result.data) setState((prev) => ({ ...prev, editorContent: definition,