SqlManagerda View, Function ve Sp Deploy özelliği
This commit is contained in:
parent
e01875b7c9
commit
953b4e7d98
2 changed files with 265 additions and 11 deletions
|
|
@ -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*)*(?<verb>CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?<type>VIEW|PROC(?:EDURE)?|FUNCTION)\s+(?<name>" + MultiPartSqlIdentifierPattern + @")",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex SqlObjectDropRegex = new(
|
||||
@"^\s*(?:(?:--[^\r\n]*|/\*[\s\S]*?\*/)\s*)*DROP\s+(?<type>VIEW|PROC(?:EDURE)?|FUNCTION)\s+(?:IF\s+EXISTS\s+)?(?<names>" + MultiPartSqlIdentifierPattern + @"(?:\s*,\s*" + MultiPartSqlIdentifierPattern + @")*)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex SqlObjectHeaderCanonicalizeRegex = new(
|
||||
@"^(?<prefix>\s*(?:(?:--[^\r\n]*\r?\n|/\*[\s\S]*?\*/)\s*)*)(?<verb>CREATE|ALTER)\s+(?<after>(?: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<SqlQueryExecutionResultDto> 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<string> 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<string> SplitSqlMultipartIdentifier(string value)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue