1007 lines
36 KiB
C#
1007 lines
36 KiB
C#
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;
|
|
|
|
/// <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.
|
|
/// </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);
|
|
|
|
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<SqlObjectManagerAppService> _logger;
|
|
|
|
public SqlObjectManagerAppService(
|
|
ISqlExecutorService sqlExecutorService,
|
|
ISqlTemplateProvider templateProvider,
|
|
IDataSourceManager dataSourceManager,
|
|
ICurrentTenant currentTenant,
|
|
IHttpContextAccessor httpContextAccessor,
|
|
IHostEnvironment hostEnvironment,
|
|
ILogger<SqlObjectManagerAppService> 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<SqlObjectExplorerDto> 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<DataSourceTypeEnum> 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<List<SqlNativeObjectDto>> 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<SqlNativeObjectDto>();
|
|
if (result.Success && result.Data != null)
|
|
{
|
|
foreach (var row in result.Data)
|
|
{
|
|
var dict = row as IDictionary<string, object>;
|
|
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<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)
|
|
{
|
|
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<DatabaseTableDto>();
|
|
if (result.Success && result.Data != null)
|
|
{
|
|
foreach (var row in result.Data)
|
|
{
|
|
var dict = row as IDictionary<string, object>;
|
|
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<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);
|
|
}
|
|
|
|
public async Task<string> 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<string, object>;
|
|
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<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"
|
|
: $@"
|
|
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<DatabaseColumnDto>();
|
|
if (result.Success && result.Data != null)
|
|
{
|
|
foreach (var row in result.Data)
|
|
{
|
|
var dict = row as IDictionary<string, object>;
|
|
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<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)
|
|
{
|
|
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<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")]
|
|
public Task<List<SqlDataFileDto>> GetSqlDataFilesAsync()
|
|
{
|
|
ValidateTenantAccess();
|
|
|
|
try
|
|
{
|
|
var outputPath = ResolveSqlDataOutputPath();
|
|
if (!Directory.Exists(outputPath))
|
|
return Task.FromResult(new List<SqlDataFileDto>());
|
|
|
|
var files = Directory.GetFiles(outputPath, "*.sql", SearchOption.TopDirectoryOnly)
|
|
.Select(f => new SqlDataFileDto
|
|
{
|
|
FileName = Path.GetFileName(f)!,
|
|
CreatedAt = File.GetCreationTime(f)
|
|
})
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.FileName))
|
|
.OrderBy(x => x.FileName, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
return Task.FromResult(files);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to list SQL seed files: {Message}", ex.Message);
|
|
return Task.FromResult(new List<SqlDataFileDto>());
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|