sozsoft-platform/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs

1126 lines
41 KiB
C#
Raw Normal View History

using System;
2026-02-24 20:44:16 +00:00
using System.Collections.Generic;
using System.IO;
2026-02-24 20:44:16 +00:00
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;
2026-02-24 20:44:16 +00:00
using Volo.Abp.Application.Services;
using Volo.Abp.MultiTenancy;
using System.Text.RegularExpressions;
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.Queries;
2026-02-24 20:44:16 +00:00
namespace Sozsoft.SqlQueryManager.Application;
/// <summary>
/// Executes T-SQL against configured data sources and exposes database metadata.
/// Does not persist SQL objects (queries, procedures, views, functions) to its own tables.
2026-02-24 20:44:16 +00:00
/// </summary>
[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);
2026-02-24 20:44:16 +00:00
private readonly ISqlExecutorService _sqlExecutorService;
private readonly ISqlTemplateProvider _templateProvider;
private readonly IDataSourceManager _dataSourceManager;
2026-02-24 20:44:16 +00:00
private readonly ICurrentTenant _currentTenant;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHostEnvironment _hostEnvironment;
private readonly ILogger<SqlObjectManagerAppService> _logger;
2026-02-24 20:44:16 +00:00
public SqlObjectManagerAppService(
ISqlExecutorService sqlExecutorService,
ISqlTemplateProvider templateProvider,
IDataSourceManager dataSourceManager,
2026-02-24 20:44:16 +00:00
ICurrentTenant currentTenant,
IHttpContextAccessor httpContextAccessor,
IHostEnvironment hostEnvironment,
ILogger<SqlObjectManagerAppService> logger)
2026-02-24 20:44:16 +00:00
{
_sqlExecutorService = sqlExecutorService;
_templateProvider = templateProvider;
_dataSourceManager = dataSourceManager;
2026-02-24 20:44:16 +00:00
_currentTenant = currentTenant;
_httpContextAccessor = httpContextAccessor;
_hostEnvironment = hostEnvironment;
_logger = logger;
2026-02-24 20:44:16 +00:00
}
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<SqlObjectExplorerDto> GetAllObjectsAsync(string dataSourceCode)
{
ValidateTenantAccess();
var result = new SqlObjectExplorerDto();
var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode);
2026-02-24 20:44:16 +00:00
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");
2026-02-24 20:44:16 +00:00
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<DataSourceTypeEnum> GetDataSourceTypeAsync(string dataSourceCode)
2026-02-24 20:44:16 +00:00
{
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<List<SqlNativeObjectDto>> GetNativeObjectsAsync(
string dataSourceCode,
DataSourceTypeEnum dataSourceType,
params string[] objectTypes)
{
var query = dataSourceType == DataSourceTypeEnum.Postgresql
? BuildPostgreSqlNativeObjectsQuery(objectTypes)
: BuildSqlServerNativeObjectsQuery(objectTypes);
2026-02-24 20:44:16 +00:00
var result = await _sqlExecutorService.ExecuteQueryAsync(query, dataSourceCode);
var objects = new List<SqlNativeObjectDto>();
2026-02-24 20:44:16 +00:00
if (result.Success && result.Data != null)
{
foreach (var row in result.Data)
{
var dict = row as IDictionary<string, object>;
2026-02-24 20:44:16 +00:00
if (dict != null)
{
objects.Add(new SqlNativeObjectDto
2026-02-24 20:44:16 +00:00
{
SchemaName = GetDictionaryValue(dict, "SchemaName")?.ToString() ?? GetDefaultSchemaName(dataSourceType),
ObjectName = GetDictionaryValue(dict, "ObjectName")?.ToString() ?? "",
DataSourceType = dataSourceType.ToString()
2026-02-24 20:44:16 +00:00
});
}
}
}
return objects;
2026-02-24 20:44:16 +00:00
}
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<string>();
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<List<DatabaseTableDto>> GetTablesAsync(string dataSourceCode, DataSourceTypeEnum dataSourceType)
2026-02-24 20:44:16 +00:00
{
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"
: @"
2026-02-24 20:44:16 +00:00
SELECT
SCHEMA_NAME(t.schema_id) AS SchemaName,
t.name AS TableName
2026-02-24 20:44:16 +00:00
FROM
sys.tables t
2026-02-24 20:44:16 +00:00
WHERE
t.is_ms_shipped = 0
2026-02-24 20:44:16 +00:00
ORDER BY
SCHEMA_NAME(t.schema_id), t.name";
2026-02-24 20:44:16 +00:00
var result = await _sqlExecutorService.ExecuteQueryAsync(query, dataSourceCode);
2026-02-24 20:44:16 +00:00
var tables = new List<DatabaseTableDto>();
2026-02-24 20:44:16 +00:00
if (result.Success && result.Data != null)
{
foreach (var row in result.Data)
{
var dict = row as IDictionary<string, object>;
2026-02-24 20:44:16 +00:00
if (dict != null)
{
tables.Add(new DatabaseTableDto
2026-02-24 20:44:16 +00:00
{
SchemaName = GetDictionaryValue(dict, "SchemaName")?.ToString() ?? GetDefaultSchemaName(dataSourceType),
TableName = GetDictionaryValue(dict, "TableName")?.ToString() ?? "",
DataSourceType = dataSourceType.ToString()
});
2026-02-24 20:44:16 +00:00
}
}
}
return tables;
2026-02-24 20:44:16 +00:00
}
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)
.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);
2026-02-24 20:44:16 +00:00
}
public async Task<string> GetNativeObjectDefinitionAsync(string dataSourceCode, string schemaName, string objectName)
{
ValidateTenantAccess();
var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode);
2026-02-24 20:44:16 +00:00
var result = await _sqlExecutorService.ExecuteQueryAsync(
BuildNativeObjectDefinitionQuery(dataSourceType, schemaName, objectName),
dataSourceCode);
2026-02-24 20:44:16 +00:00
if (result.Success && result.Data != null)
{
var dataList = result.Data.ToList();
if (dataList.Count > 0)
{
var row = dataList[0] as IDictionary<string, object>;
if (row != null)
2026-02-24 20:44:16 +00:00
{
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;
2026-02-24 20:44:16 +00:00
}
}
}
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";
}
2026-02-24 20:44:16 +00:00
public async Task<List<DatabaseColumnDto>> 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"
: $@"
2026-02-24 20:44:16 +00:00
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)}
2026-02-24 20:44:16 +00:00
ORDER BY
c.column_id";
var result = await _sqlExecutorService.ExecuteQueryAsync(query, dataSourceCode);
var columns = new List<DatabaseColumnDto>();
if (result.Success && result.Data != null)
{
foreach (var row in result.Data)
{
var dict = row as IDictionary<string, object>;
2026-02-24 20:44:16 +00:00
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"))
2026-02-24 20:44:16 +00:00
});
}
}
}
return columns;
}
[HttpGet("api/app/sql-object-manager/table-create-script")]
public async Task<string> 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<string, object>;
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<string, object> 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)
2026-02-24 20:44:16 +00:00
{
return new SqlQueryExecutionResultDto
{
Success = result.Success,
Message = result.Success
? (isDeployed ? QueryExecutedAndDeployedMessage : QueryExecutedSuccessfullyMessage)
: result.Message,
2026-02-24 20:44:16 +00:00
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<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)
{
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")]
2026-05-27 12:36:46 +00:00
public Task<List<SqlDataFileDto>> GetSqlDataFilesAsync(
[FromQuery] string dataDirectoryName = "SqlData",
[FromQuery] string relativePath = "")
{
ValidateTenantAccess();
try
{
2026-05-27 12:36:46 +00:00
var rootPath = ResolveSqlDataOutputPath(dataDirectoryName);
var outputPath = ResolveSqlDataChildPath(rootPath, relativePath);
if (!Directory.Exists(outputPath))
return Task.FromResult(new List<SqlDataFileDto>());
2026-05-27 12:36:46 +00:00
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)!,
2026-05-27 12:36:46 +00:00
Name = Path.GetFileName(f)!,
RelativePath = BuildSqlDataRelativePath(relativePath, Path.GetFileName(f)!),
IsDirectory = false,
CreatedAt = File.GetCreationTime(f)
})
2026-05-27 12:36:46 +00:00
.Where(x => !string.IsNullOrWhiteSpace(x.Name));
var entries = directories
.Concat(files)
.OrderByDescending(x => x.IsDirectory)
.ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
2026-05-27 12:36:46 +00:00
return Task.FromResult(entries);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list SQL seed files: {Message}", ex.Message);
return Task.FromResult(new List<SqlDataFileDto>());
}
}
2026-05-27 12:36:46 +00:00
[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()
2026-05-27 12:36:46 +00:00
{
return ResolveSqlDataOutputPath("SqlData");
}
private string ResolveSqlDataOutputPath(string dataDirectoryName)
{
const string dbMigratorName = "Sozsoft.Platform.DbMigrator";
2026-05-27 12:36:46 +00:00
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))
2026-05-27 12:36:46 +00:00
return Path.Combine(candidate, safeDirectoryName);
candidate = Path.Combine(dir.FullName, dbMigratorName, "Seeds");
if (Directory.Exists(candidate))
2026-05-27 12:36:46 +00:00
return Path.Combine(candidate, safeDirectoryName);
dir = dir.Parent;
}
2026-05-27 12:36:46 +00:00
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}";
}
2026-02-24 20:44:16 +00:00
}