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; using Sozsoft.Platform.Enums; using Sozsoft.Platform.Queries; 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 IDataSourceManager _dataSourceManager; private readonly ICurrentTenant _currentTenant; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHostEnvironment _hostEnvironment; private readonly ILogger _logger; public SqlObjectManagerAppService( ISqlExecutorService sqlExecutorService, ISqlTemplateProvider templateProvider, IDataSourceManager dataSourceManager, ICurrentTenant currentTenant, IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment, ILogger logger) { _sqlExecutorService = sqlExecutorService; _templateProvider = templateProvider; _dataSourceManager = dataSourceManager; _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(); var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode); result.Tables = await GetTablesAsync(dataSourceCode, dataSourceType); result.Views = await GetNativeObjectsAsync(dataSourceCode, dataSourceType, "V"); result.StoredProcedures = await GetNativeObjectsAsync(dataSourceCode, dataSourceType, "P"); result.Functions = await GetNativeObjectsAsync(dataSourceCode, dataSourceType, "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 GetDataSourceTypeAsync(string dataSourceCode) { var dataSource = await _dataSourceManager.GetDataSourceAsync(_currentTenant.IsAvailable, dataSourceCode); if (dataSource == null) { throw new Volo.Abp.UserFriendlyException($"Data source '{dataSourceCode}' was not found."); } return dataSource.DataSourceType; } private async Task> GetNativeObjectsAsync( string dataSourceCode, DataSourceTypeEnum dataSourceType, params string[] objectTypes) { var query = dataSourceType == DataSourceTypeEnum.Postgresql ? BuildPostgreSqlNativeObjectsQuery(objectTypes) : BuildSqlServerNativeObjectsQuery(objectTypes); 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 = GetDictionaryValue(dict, "SchemaName")?.ToString() ?? GetDefaultSchemaName(dataSourceType), ObjectName = GetDictionaryValue(dict, "ObjectName")?.ToString() ?? "", DataSourceType = dataSourceType.ToString() }); } } } return objects; } private static string BuildSqlServerNativeObjectsQuery(params string[] objectTypes) { var typeList = string.Join(",", objectTypes.Select(t => $"'{t}'")); return $@" 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"; } private static string BuildPostgreSqlNativeObjectsQuery(params string[] objectTypes) { var wantsViews = objectTypes.Contains("V"); var wantsProcedures = objectTypes.Contains("P"); var wantsFunctions = objectTypes.Any(t => t is "FN" or "IF" or "TF"); if (wantsViews) { return @" SELECT table_schema AS ""SchemaName"", table_name AS ""ObjectName"" FROM information_schema.views WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name"; } var proKinds = new List(); if (wantsProcedures) { proKinds.Add("'p'"); } if (wantsFunctions) { proKinds.Add("'f'"); } return $@" SELECT n.nspname AS ""SchemaName"", p.proname AS ""ObjectName"" FROM pg_proc p INNER JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND p.prokind IN ({string.Join(",", proKinds)}) ORDER BY n.nspname, p.proname"; } private async Task> GetTablesAsync(string dataSourceCode, DataSourceTypeEnum dataSourceType) { var query = dataSourceType == DataSourceTypeEnum.Postgresql ? @" SELECT table_schema AS ""SchemaName"", table_name AS ""TableName"" FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name" : @" 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 = GetDictionaryValue(dict, "SchemaName")?.ToString() ?? GetDefaultSchemaName(dataSourceType), TableName = GetDictionaryValue(dict, "TableName")?.ToString() ?? "", DataSourceType = dataSourceType.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 dataSourceType = await GetDataSourceTypeAsync(dataSourceCode); var result = await _sqlExecutorService.ExecuteQueryAsync( BuildNativeObjectDefinitionQuery(dataSourceType, schemaName, objectName), 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) { var definition = GetDictionaryValue(row, "Definition")?.ToString() ?? string.Empty; // Always open object script as CREATE OR ALTER in editor. if (dataSourceType == DataSourceTypeEnum.Mssql) { definition = Regex.Replace( definition, @"^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?", "CREATE OR ALTER ", RegexOptions.IgnoreCase); } return definition; } } } return string.Empty; } private static string BuildNativeObjectDefinitionQuery( DataSourceTypeEnum dataSourceType, string schemaName, string objectName) { if (dataSourceType == DataSourceTypeEnum.Postgresql) { var schema = ToSqlLiteral(schemaName); var name = ToSqlLiteral(objectName); return $@" SELECT ""Definition"" FROM ( SELECT pg_get_viewdef(c.oid, true) AS ""Definition"" FROM pg_class c INNER JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = {schema} AND c.relname = {name} AND c.relkind IN ('v', 'm') UNION ALL SELECT pg_get_functiondef(p.oid) AS ""Definition"" FROM pg_proc p INNER JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = {schema} AND p.proname = {name} AND p.prokind IN ('f', 'p') ) d LIMIT 1"; } var fullObjectName = $"[{schemaName.Replace("]", "]]")}].[{objectName.Replace("]", "]]")}]"; return $@" SELECT OBJECT_DEFINITION(OBJECT_ID({ToSqlLiteral(fullObjectName)})) AS Definition"; } public async Task> GetTableColumnsAsync(string dataSourceCode, string schemaName, string tableName) { ValidateTenantAccess(); var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode); var query = dataSourceType == DataSourceTypeEnum.Postgresql ? $@" SELECT column_name AS ""ColumnName"", COALESCE(NULLIF(udt_name, ''), data_type) AS ""DataType"", CASE WHEN is_nullable = 'YES' THEN TRUE ELSE FALSE END AS ""IsNullable"", character_maximum_length AS ""MaxLength"" FROM information_schema.columns WHERE table_schema = {ToSqlLiteral(schemaName)} AND table_name = {ToSqlLiteral(tableName)} ORDER BY ordinal_position" : $@" 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 = {ToSqlLiteral(schemaName)} AND t.name = {ToSqlLiteral(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 = GetDictionaryValue(dict, "ColumnName")?.ToString() ?? "", DataType = GetDictionaryValue(dict, "DataType")?.ToString() ?? "", IsNullable = ToBoolean(GetDictionaryValue(dict, "IsNullable")), MaxLength = ToNullableInt(GetDictionaryValue(dict, "MaxLength")) }); } } } return columns; } [HttpGet("api/app/sql-object-manager/table-create-script")] public async Task GetTableCreateScriptAsync(string dataSourceCode, string schemaName, string tableName) { ValidateTenantAccess(); var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode); var result = await _sqlExecutorService.ExecuteQueryAsync( BuildTableCreateScriptQuery(dataSourceType, schemaName, tableName), dataSourceCode); if (!result.Success || result.Data == null) { return string.Empty; } var row = result.Data.FirstOrDefault() as IDictionary; return GetDictionaryValue(row, "Script")?.ToString() ?? string.Empty; } private static string BuildTableCreateScriptQuery( DataSourceTypeEnum dataSourceType, string schemaName, string tableName) { if (dataSourceType == DataSourceTypeEnum.Postgresql) { return BuildPostgreSqlTableCreateScriptQuery(schemaName, tableName); } return BuildSqlServerTableCreateScriptQuery(schemaName, tableName); } private static string BuildSqlServerTableCreateScriptQuery(string schemaName, string tableName) { var fullName = $"[{schemaName.Replace("]", "]]")}].[{tableName.Replace("]", "]]")}]"; var escapedFullName = ToSqlLiteral(fullName); return $@" DECLARE @ObjectId INT = OBJECT_ID({escapedFullName}); IF @ObjectId IS NULL BEGIN SELECT CAST('' AS NVARCHAR(MAX)) AS Script; RETURN; END; ;WITH cols AS ( SELECT c.column_id, ' ' + QUOTENAME(c.name) + ' ' + CASE WHEN t.name IN ('varchar', 'char', 'varbinary', 'binary') THEN t.name + '(' + CASE WHEN c.max_length = -1 THEN 'MAX' ELSE CAST(c.max_length AS VARCHAR(10)) END + ')' WHEN t.name IN ('nvarchar', 'nchar') THEN t.name + '(' + CASE WHEN c.max_length = -1 THEN 'MAX' ELSE CAST(c.max_length / 2 AS VARCHAR(10)) END + ')' WHEN t.name IN ('decimal', 'numeric') THEN t.name + '(' + CAST(c.precision AS VARCHAR(10)) + ',' + CAST(c.scale AS VARCHAR(10)) + ')' WHEN t.name IN ('datetime2', 'datetimeoffset', 'time') THEN t.name + '(' + CAST(c.scale AS VARCHAR(10)) + ')' ELSE t.name END + CASE WHEN ic.object_id IS NOT NULL THEN ' IDENTITY(' + CAST(ic.seed_value AS VARCHAR(30)) + ',' + CAST(ic.increment_value AS VARCHAR(30)) + ')' ELSE '' END + CASE WHEN c.is_nullable = 1 THEN ' NULL' ELSE ' NOT NULL' END + ISNULL(' DEFAULT ' + dc.definition, '') AS line FROM sys.columns c INNER JOIN sys.types t ON c.user_type_id = t.user_type_id LEFT JOIN sys.identity_columns ic ON c.object_id = ic.object_id AND c.column_id = ic.column_id LEFT JOIN sys.default_constraints dc ON c.default_object_id = dc.object_id WHERE c.object_id = @ObjectId ), pk AS ( SELECT ' CONSTRAINT ' + QUOTENAME(k.name) + ' PRIMARY KEY ' + CASE WHEN i.type = 1 THEN 'CLUSTERED' ELSE 'NONCLUSTERED' END + CHAR(13) + CHAR(10) + ' (' + CHAR(13) + CHAR(10) + ( SELECT ' ' + QUOTENAME(c.name) + CASE WHEN ic.is_descending_key = 1 THEN ' DESC' ELSE ' ASC' END + CHAR(13) + CHAR(10) FROM sys.index_columns ic INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 ORDER BY ic.key_ordinal FOR XML PATH(''), TYPE ).value('.', 'NVARCHAR(MAX)') + ' )' AS line FROM sys.key_constraints k INNER JOIN sys.indexes i ON k.parent_object_id = i.object_id AND k.unique_index_id = i.index_id WHERE k.parent_object_id = @ObjectId AND k.type = 'PK' ) SELECT 'IF OBJECT_ID(N''{fullName.Replace("'", "''")}'', ''U'') IS NULL' + CHAR(13) + CHAR(10) + 'BEGIN' + CHAR(13) + CHAR(10) + ' CREATE TABLE {fullName}' + CHAR(13) + CHAR(10) + ' (' + CHAR(13) + CHAR(10) + STUFF( ( SELECT ',' + CHAR(13) + CHAR(10) + line FROM cols ORDER BY column_id FOR XML PATH(''), TYPE ).value('.', 'NVARCHAR(MAX)') ,1,3,'') + ISNULL( ( SELECT ',' + CHAR(13) + CHAR(10) + line FROM pk ), '' ) + CHAR(13) + CHAR(10) + ' )' + CHAR(13) + CHAR(10) + 'END' + CHAR(13) + CHAR(10) + 'GO' AS Script;"; } private static string BuildPostgreSqlTableCreateScriptQuery(string schemaName, string tableName) { return $@" WITH cols AS ( SELECT c.ordinal_position, ' ' || quote_ident(c.column_name) || ' ' || CASE WHEN c.data_type = 'character varying' THEN 'varchar(' || c.character_maximum_length || ')' WHEN c.data_type = 'character' THEN 'char(' || c.character_maximum_length || ')' WHEN c.data_type = 'numeric' AND c.numeric_precision IS NOT NULL THEN 'numeric(' || c.numeric_precision || ',' || c.numeric_scale || ')' WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END || CASE WHEN c.is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END || COALESCE(' DEFAULT ' || c.column_default, '') AS line FROM information_schema.columns c WHERE c.table_schema = {ToSqlLiteral(schemaName)} AND c.table_name = {ToSqlLiteral(tableName)} ), pk AS ( SELECT ' CONSTRAINT ' || quote_ident(tc.constraint_name) || ' PRIMARY KEY (' || string_agg(quote_ident(kcu.column_name), ', ' ORDER BY kcu.ordinal_position) || ')' AS line FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = {ToSqlLiteral(schemaName)} AND tc.table_name = {ToSqlLiteral(tableName)} GROUP BY tc.constraint_name ) SELECT 'CREATE TABLE IF NOT EXISTS ' || quote_ident({ToSqlLiteral(schemaName)}) || '.' || quote_ident({ToSqlLiteral(tableName)}) || E'\n(\n' || string_agg(line, E',\n' ORDER BY sort_order) || E'\n);' AS ""Script"" FROM ( SELECT ordinal_position AS sort_order, line FROM cols UNION ALL SELECT 100000 AS sort_order, line FROM pk ) s;"; } private static object GetDictionaryValue(IDictionary row, string key) { if (row == null) { return null; } if (row.TryGetValue(key, out var value)) { return value; } var match = row.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase)); return string.IsNullOrEmpty(match.Key) ? null : match.Value; } private static string GetDefaultSchemaName(DataSourceTypeEnum dataSourceType) { return dataSourceType == DataSourceTypeEnum.Postgresql ? "public" : "dbo"; } private static string ToSqlLiteral(string value) { return $"'{(value ?? string.Empty).Replace("'", "''")}'"; } private static bool ToBoolean(object value) { return value switch { bool boolValue => boolValue, short shortValue => shortValue != 0, int intValue => intValue != 0, long longValue => longValue != 0, _ => bool.TryParse(value?.ToString(), out var parsed) && parsed }; } private static int? ToNullableInt(object value) { if (value == null) { return null; } return int.TryParse(value.ToString(), out var parsed) ? parsed : null; } 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; } [HttpGet("api/app/sql-object-manager/sql-data-files")] public Task> GetSqlDataFilesAsync( [FromQuery] string dataDirectoryName = "SqlData", [FromQuery] string relativePath = "") { ValidateTenantAccess(); try { var rootPath = ResolveSqlDataOutputPath(dataDirectoryName); var outputPath = ResolveSqlDataChildPath(rootPath, relativePath); if (!Directory.Exists(outputPath)) return Task.FromResult(new List()); var directories = Directory.GetDirectories(outputPath, "*", SearchOption.TopDirectoryOnly) .Where(d => string.Equals(Path.GetFileName(d), "HostData", StringComparison.OrdinalIgnoreCase)) .Select(d => new SqlDataFileDto { FileName = Path.GetFileName(d)!, Name = Path.GetFileName(d)!, RelativePath = BuildSqlDataRelativePath(relativePath, Path.GetFileName(d)!), IsDirectory = true, CreatedAt = Directory.GetCreationTime(d) }); var files = Directory.GetFiles(outputPath, "*.sql", SearchOption.TopDirectoryOnly) .Select(f => new SqlDataFileDto { FileName = Path.GetFileName(f)!, Name = Path.GetFileName(f)!, RelativePath = BuildSqlDataRelativePath(relativePath, Path.GetFileName(f)!), IsDirectory = false, CreatedAt = File.GetCreationTime(f) }) .Where(x => !string.IsNullOrWhiteSpace(x.Name)); var entries = directories .Concat(files) .OrderByDescending(x => x.IsDirectory) .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .ToList(); return Task.FromResult(entries); } catch (Exception ex) { _logger.LogError(ex, "Failed to list SQL seed files: {Message}", ex.Message); return Task.FromResult(new List()); } } [HttpGet("api/app/sql-object-manager/sql-data-file-content")] public async Task GetSqlDataFileContentAsync( [FromQuery] string dataDirectoryName = "SqlData", [FromQuery] string relativePath = "") { ValidateTenantAccess(); var rootPath = ResolveSqlDataOutputPath(dataDirectoryName); var filePath = ResolveSqlDataChildPath(rootPath, relativePath); if (!File.Exists(filePath)) throw new Volo.Abp.UserFriendlyException("SQL seed file was not found."); if (!string.Equals(Path.GetExtension(filePath), ".sql", StringComparison.OrdinalIgnoreCase)) throw new Volo.Abp.UserFriendlyException("Only .sql files can be previewed."); return await File.ReadAllTextAsync(filePath); } [HttpPost("api/app/sql-object-manager/move-sql-data-file")] public Task MoveSqlDataFileAsync(MoveSqlDataFileDto input) { ValidateTenantAccess(); if (input == null) throw new Volo.Abp.UserFriendlyException("Invalid move request."); var rootPath = ResolveSqlDataOutputPath(input.DataDirectoryName); var sourcePath = ResolveSqlDataChildPath(rootPath, input.SourceRelativePath); var targetPath = ResolveSqlDataChildPath(rootPath, input.TargetRelativePath); if (!File.Exists(sourcePath)) throw new Volo.Abp.UserFriendlyException("Source file was not found."); if (!string.Equals(Path.GetExtension(sourcePath), ".sql", StringComparison.OrdinalIgnoreCase) || !string.Equals(Path.GetExtension(targetPath), ".sql", StringComparison.OrdinalIgnoreCase)) { throw new Volo.Abp.UserFriendlyException("Only .sql files can be moved."); } Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); if (File.Exists(targetPath)) throw new Volo.Abp.UserFriendlyException("A file with the same name already exists in the target folder."); File.Move(sourcePath, targetPath); _logger.LogInformation("SQL seed file moved from {SourcePath} to {TargetPath}", sourcePath, targetPath); return Task.CompletedTask; } private string ResolveSqlDataOutputPath() { return ResolveSqlDataOutputPath("SqlData"); } private string ResolveSqlDataOutputPath(string dataDirectoryName) { const string dbMigratorName = "Sozsoft.Platform.DbMigrator"; var safeDirectoryName = NormalizeSqlDataDirectoryName(dataDirectoryName); 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, safeDirectoryName); candidate = Path.Combine(dir.FullName, dbMigratorName, "Seeds"); if (Directory.Exists(candidate)) return Path.Combine(candidate, safeDirectoryName); dir = dir.Parent; } return Path.Combine(_hostEnvironment.ContentRootPath, "Seeds", safeDirectoryName); } private static string NormalizeSqlDataDirectoryName(string dataDirectoryName) { return string.Equals(dataDirectoryName, "PostgresData", StringComparison.OrdinalIgnoreCase) ? "PostgresData" : "SqlData"; } private static string ResolveSqlDataChildPath(string rootPath, string relativePath) { var normalized = NormalizeSqlDataRelativePath(relativePath); var fullPath = Path.GetFullPath(Path.Combine(rootPath, normalized)); var fullRoot = Path.GetFullPath(rootPath); var fullRootWithSeparator = fullRoot.EndsWith(Path.DirectorySeparatorChar) ? fullRoot : fullRoot + Path.DirectorySeparatorChar; if (!string.Equals(fullPath, fullRoot, StringComparison.OrdinalIgnoreCase) && !fullPath.StartsWith(fullRootWithSeparator, StringComparison.OrdinalIgnoreCase)) { throw new Volo.Abp.UserFriendlyException("Invalid path."); } return fullPath; } private static string NormalizeSqlDataRelativePath(string relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) return string.Empty; var normalized = relativePath.Replace('\\', '/').Trim('/'); var parts = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) return string.Empty; if (parts.Any(p => p == "." || p == ".." || p.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)) throw new Volo.Abp.UserFriendlyException("Invalid path."); var isRootFile = parts.Length == 1 && parts[0].EndsWith(".sql", StringComparison.OrdinalIgnoreCase); var isHostDataFolder = parts.Length == 1 && string.Equals(parts[0], "HostData", StringComparison.OrdinalIgnoreCase); var isHostDataFile = parts.Length == 2 && string.Equals(parts[0], "HostData", StringComparison.OrdinalIgnoreCase) && parts[1].EndsWith(".sql", StringComparison.OrdinalIgnoreCase); if (!isRootFile && !isHostDataFolder && !isHostDataFile) throw new Volo.Abp.UserFriendlyException("Invalid path."); return Path.Combine(parts); } private static string BuildSqlDataRelativePath(string parentRelativePath, string name) { if (string.IsNullOrWhiteSpace(parentRelativePath)) return name; return $"{parentRelativePath.Trim('/', '\\')}/{name}"; } }