sozsoft-platform/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs
2026-10-17 21:51:10 +03:00

920 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.Localization;
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 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;
LocalizationResource = typeof(PlatformResource);
}
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(L["App.SqlQueryManager.TenantMismatch", headerTenant, currentTenantName]);
}
}
}
public async Task<SqlObjectExplorerDto> GetAllObjectsAsync(string dataSourceCode)
{
ValidateTenantAccess();
var result = new SqlObjectExplorerDto();
var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode);
result.Tables = await GetTablesAsync(dataSourceCode, dataSourceType);
var nativeObjects = await GetNativeObjectsAsync(dataSourceCode, dataSourceType);
result.Views = OfKinds(nativeObjects, SqlCatalogQueries.ObjectKinds.View);
result.StoredProcedures = OfKinds(nativeObjects, SqlCatalogQueries.ObjectKinds.Procedure);
result.Functions = OfKinds(nativeObjects, SqlCatalogQueries.ObjectKinds.ScalarFunction, SqlCatalogQueries.ObjectKinds.TableFunction);
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(L["App.SqlQueryManager.DataSourceNotFound", dataSourceCode]);
}
return dataSource.DataSourceType;
}
/// <summary>
/// Katalog bir kez okunur; tür başına liste, katalog sırasını (şema + ad) koruyarak süzülür.
/// </summary>
private async Task<List<(string Kind, SqlNativeObjectDto Dto)>> GetNativeObjectsAsync(
string dataSourceCode,
DataSourceTypeEnum dataSourceType)
{
var result = await _sqlExecutorService.ExecuteQueryAsync(SqlCatalogQueries.NativeObjects(dataSourceType), dataSourceCode);
var objects = new List<(string Kind, SqlNativeObjectDto Dto)>();
if (result.Success && result.Data != null)
{
foreach (var row in result.Data)
{
if (row is IDictionary<string, object> dict)
{
objects.Add((
GetDictionaryValue(dict, "ObjectKind")?.ToString() ?? "",
new SqlNativeObjectDto
{
SchemaName = GetDictionaryValue(dict, "SchemaName")?.ToString() ?? SqlIdentifier.DefaultSchema(dataSourceType),
ObjectName = GetDictionaryValue(dict, "ObjectName")?.ToString() ?? "",
DataSourceType = dataSourceType.ToString()
}));
}
}
}
return objects;
}
private static List<SqlNativeObjectDto> OfKinds(List<(string Kind, SqlNativeObjectDto Dto)> objects, params string[] kinds)
=> [.. objects.Where(o => kinds.Contains(o.Kind)).Select(o => o.Dto)];
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() ?? SqlIdentifier.DefaultSchema(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 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
? L[isDeployed ? "App.SqlQueryManager.QueryExecutedAndDeployed" : "App.Platform.QueryExecutedSuccessfully"]
: 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(L["App.Platform.InvalidFileName"]);
}
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),
];
}