975 lines
36 KiB
C#
975 lines
36 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text.RegularExpressions;
|
||
using System.Threading.Tasks;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.Logging;
|
||
using Sozsoft.Platform.Data.Seeds;
|
||
using Sozsoft.Platform.Enums;
|
||
using Sozsoft.Platform.Queries;
|
||
using Sozsoft.Settings;
|
||
using Sozsoft.SqlQueryManager.Application.Contracts;
|
||
using Sozsoft.SqlQueryManager.Domain.Services;
|
||
using Volo.Abp.Application.Services;
|
||
using Volo.Abp.MultiTenancy;
|
||
|
||
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 IConfiguration _configuration;
|
||
private readonly ILogger<SqlObjectManagerAppService> _logger;
|
||
|
||
public SqlObjectManagerAppService(
|
||
ISqlExecutorService sqlExecutorService,
|
||
ISqlTemplateProvider templateProvider,
|
||
IDataSourceManager dataSourceManager,
|
||
ICurrentTenant currentTenant,
|
||
IHttpContextAccessor httpContextAccessor,
|
||
IConfiguration configuration,
|
||
ILogger<SqlObjectManagerAppService> logger)
|
||
{
|
||
_sqlExecutorService = sqlExecutorService;
|
||
_templateProvider = templateProvider;
|
||
_dataSourceManager = dataSourceManager;
|
||
_currentTenant = currentTenant;
|
||
_httpContextAccessor = httpContextAccessor;
|
||
_configuration = configuration;
|
||
_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
|
||
{
|
||
// Tasarımcı scripti T-SQL'dir; uygulama iki sağlayıcıda da kurulabildiği için PostgreSQL
|
||
// eşi aynı anda yazılır (postgres/object).
|
||
var safeFileName = string.Concat(input.FileName.Trim().Split(Path.GetInvalidFileNameChars()));
|
||
var (sqlServerPath, postgreSqlPath) = PostgreSqlTableScript.WriteSeedFiles(_configuration, safeFileName, input.SqlScript);
|
||
|
||
_logger.LogInformation("SQL seed file saved: {FilePath}", sqlServerPath);
|
||
|
||
if (postgreSqlPath is null)
|
||
_logger.LogWarning("PostgreSQL counterpart of {FilePath} could not be generated; write it by hand.", sqlServerPath);
|
||
}
|
||
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 safeFileName = string.Concat(fileName.Split(Path.GetInvalidFileNameChars()));
|
||
if (string.IsNullOrWhiteSpace(safeFileName))
|
||
return;
|
||
|
||
foreach (var filePath in ResolveSeedObjectPaths().Select(path => Path.Combine(path, $"{safeFileName}.sql")).Where(File.Exists))
|
||
{
|
||
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
|
||
{
|
||
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;
|
||
|
||
foreach (var filePath in ResolveSeedObjectPaths().Select(path => Path.Combine(path, $"{safeFileName}.sql")).Where(File.Exists))
|
||
{
|
||
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;
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// Script dosyalarinin yazilacagi klasor. SqlDataSeeder ile ayni cozumleyiciyi kullanir
|
||
/// (App:SeedsPath -> configs/seeds), aksi halde deploy edilen nesne seed olarak geri okunmaz.
|
||
/// Dosya <c>{sql|postgres}/object</c> altindadir; nesne host'ta ve kendi veritabani olan her
|
||
/// tenant'ta seed sirasinda yeniden olusturulur.
|
||
/// </summary>
|
||
private string ResolveSqlDataOutputPath()
|
||
{
|
||
var providerDirectoryName = SeedPathResolver.GetProviderFolderName(
|
||
SettingsConsts.DefaultDatabaseProvider == SettingsConsts.DatabaseProvider.PostgreSql);
|
||
|
||
return SeedPathResolver.GetPath(_configuration, providerDirectoryName, SeedPathResolver.ObjectFolder);
|
||
}
|
||
|
||
/// <summary>
|
||
/// İki sağlayıcının <c>object</c> klasörü. Düşürülen nesnenin dosyası ikisinden de silinir; yalnızca
|
||
/// aktif sağlayıcıdan silinseydi diğer sağlayıcıdaki kurulum nesneyi yeniden yaratırdı.
|
||
/// </summary>
|
||
private string[] ResolveSeedObjectPaths() =>
|
||
[
|
||
SeedPathResolver.GetPath(_configuration, SeedPathResolver.SqlFolder, SeedPathResolver.ObjectFolder),
|
||
SeedPathResolver.GetPath(_configuration, SeedPathResolver.PostgresFolder, SeedPathResolver.ObjectFolder),
|
||
];
|
||
|
||
}
|