using System.Text.RegularExpressions;
using Dapper;
using GB5Shared.DTO.Framework.SchemaIntrospection;
using GB5Shared.EncryptionHelper;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SwBLL.Vault;
using SwDAL.DTO.ClientDatabase;
using SwDAL.DTO.DbServer;
using SwDAL.Enums;
namespace SwBLL.Provisioning;
///
/// SQL Server implementation of ITargetDbExecutor.
/// Credentials always come from DbServerDTO — never Integrated Security.
/// Row cap on SELECT: 5 000 rows. Multi-statement scripts are split on GO separator.
///
public class TargetDbExecutor : ITargetDbExecutor
{
private const int SelectRowCap = 5_000;
/// Row cap for only — a separate, higher cap
/// than since that path serves full server-assembled report
/// execution rather than ad-hoc exploratory queries. Does not affect SelectRowCap or any
/// other existing method.
private const int InternalBatchRowCap = 100_000;
private static readonly Regex _selectOnlyRegex =
new(@"^\s*(SELECT|WITH)\b", RegexOptions.IgnoreCase | RegexOptions.Multiline);
private static readonly Regex _nonSelectRegex =
new(@"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|DENY)\b",
RegexOptions.IgnoreCase);
private readonly IConfiguration _configuration;
private readonly ISwVaultService _swVaultService;
private readonly ILogger _logger;
public TargetDbExecutor(
IConfiguration configuration, ISwVaultService swVaultService, ILogger logger)
{
_configuration = configuration;
_swVaultService = swVaultService;
_logger = logger;
}
// ── Connection string builder ─────────────────────────────────────────
public string BuildConnectionString(DbServerDTO server,string databaseName,string? dbUsername = null,string? dbPassword = null)
{
var username = string.IsNullOrWhiteSpace(dbUsername)
? server.DbUsername
: dbUsername;
var password = string.IsNullOrWhiteSpace(dbPassword)
? DecryptServerPassword(server)
: dbPassword;
if (string.IsNullOrWhiteSpace(username))
{
throw new InvalidOperationException(
"Database username is not configured.");
}
if (string.IsNullOrWhiteSpace(password))
{
throw new InvalidOperationException(
"Database password is not configured.");
}
var csb = new SqlConnectionStringBuilder
{
DataSource = server.Port == 0
? server.HostName
: $"{server.HostName},{server.Port}",
InitialCatalog = databaseName,
UserID = username,
Password = password,
TrustServerCertificate = true,
Encrypt = false,
ConnectTimeout = 30
};
return csb.ConnectionString;
}
public string BuildAdminConnectionString(DbServerDTO server)
=> BuildConnectionString(server, "master");
// ── Role-aware connection string builder (contained users) ───────────
public async Task BuildConnectionStringForRoleAsync(
DbServerDTO server, ClientDatabaseDTO clientDb, ClientDbLoginRole role, CancellationToken ct)
{
var (vaultPath, roleSuffix) = role switch
{
ClientDbLoginRole.Dba => (clientDb.DbaVaultPath, "dba"),
ClientDbLoginRole.App => (clientDb.AppVaultPath, "app"),
ClientDbLoginRole.ReadOnly => (clientDb.ReadOnlyVaultPath, "readonly"),
_ => throw new ArgumentOutOfRangeException(nameof(role), role, "Unhandled ClientDbLoginRole.")
};
if (string.IsNullOrWhiteSpace(vaultPath))
{
_logger.LogWarning(
"No {Role} contained-user credential provisioned for ClientDatabaseId {ClientDbId}; falling back to DbServer admin credential.",
role, clientDb.ClientDbId);
return BuildConnectionString(server, clientDb.DatabaseName);
}
var password = await _swVaultService.GetSecretAsync(vaultPath, ct).ConfigureAwait(false);
var username = $"{clientDb.ClientDbCode}_{roleSuffix}";
return BuildConnectionString(server, clientDb.DatabaseName, username, password);
}
private string DecryptServerPassword(DbServerDTO server)
{
if (string.IsNullOrWhiteSpace(server.DbPassword))
{
throw new InvalidOperationException(
$"DB password is not configured for DB server '{server.DbServerId}'.");
}
string encryptionKey = _configuration["Encryption:DbPasswordKey"]
?? throw new InvalidOperationException("DB password encryption key is not configured.");
try
{
return PasswordEncryption.Decrypt(server.DbPassword, encryptionKey);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Unable to decrypt DB password for DB server '{server.DbServerId}'.", ex);
}
}
// ── DDL / DML execution ───────────────────────────────────────────────
public async Task ExecuteScriptAsync(string connectionString, string sql, CancellationToken ct)
{
// Split on GO batches (case-insensitive, standalone line)
var batches = Regex.Split(sql, @"^\s*GO\s*$",
RegexOptions.Multiline | RegexOptions.IgnoreCase)
.Where(b => !string.IsNullOrWhiteSpace(b))
.ToList();
int totalRows = 0;
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
foreach (var batch in batches)
{
var rows = await conn.ExecuteAsync(new CommandDefinition(batch, cancellationToken: ct))
.ConfigureAwait(false);
totalRows += rows;
}
return totalRows;
}
// ── Parameterized DML ─────────────────────────────────────────────────
public async Task ExecuteDmlAsync(string connectionString, string sql,
Dictionary parameters, CancellationToken ct)
{
var dynParams = new DynamicParameters();
foreach (var kv in parameters)
dynParams.Add(kv.Key, kv.Value);
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return await conn.ExecuteAsync(new CommandDefinition(sql, dynParams, cancellationToken: ct))
.ConfigureAwait(false);
}
// ── SELECT-only query ─────────────────────────────────────────────────
public async Task>> ExecuteQueryAsync(
string connectionString, string sql,
Dictionary parameters,
CancellationToken ct)
{
EnforceSelectOnly(sql);
var dynParams = new DynamicParameters();
foreach (var kv in parameters)
dynParams.Add(kv.Key, kv.Value);
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
var rows = (await conn.QueryAsync(
new CommandDefinition(sql, dynParams, cancellationToken: ct))
.ConfigureAwait(false)).ToList();
if (rows.Count > SelectRowCap)
throw new InvalidOperationException(
$"Query returned {rows.Count} rows which exceeds the cap of {SelectRowCap}.");
return rows.Select(r =>
{
var dict = (IDictionary)r;
return dict.ToDictionary(kv => kv.Key, kv => (object?)kv.Value);
});
}
// ── Multi-statement server-assembled batch execution (internal-only) ─────
public async Task>> ExecuteQueryBatchAsync(
string connectionString, string sql,
Dictionary parameters,
int? rowCapOverride, CancellationToken ct)
{
EnforceSelectOnly(sql);
var dynParams = new DynamicParameters();
foreach (var kv in parameters)
dynParams.Add(kv.Key, kv.Value);
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
var rows = (await conn.QueryAsync(
new CommandDefinition(sql, dynParams, cancellationToken: ct))
.ConfigureAwait(false)).ToList();
var effectiveCap = rowCapOverride ?? InternalBatchRowCap;
if (rows.Count > effectiveCap)
throw new InvalidOperationException(
$"Query returned {rows.Count} rows which exceeds the cap of {effectiveCap}.");
return rows.Select(r =>
{
var dict = (IDictionary)r;
return dict.ToDictionary(kv => kv.Key, kv => (object?)kv.Value);
});
}
// ── Schema introspection ──────────────────────────────────────────────
public async Task> GetServerObjectsAsync(
string connectionString, CancellationToken ct)
{
const string sql = @"
SELECT TABLE_SCHEMA AS SchemaName,
TABLE_NAME AS ObjectName,
TABLE_TYPE AS ObjectType
FROM INFORMATION_SCHEMA.TABLES
UNION ALL
SELECT SPECIFIC_SCHEMA, SPECIFIC_NAME, ROUTINE_TYPE
FROM INFORMATION_SCHEMA.ROUTINES
ORDER BY SchemaName, ObjectType, ObjectName";
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return await conn.QueryAsync(
new CommandDefinition(sql, cancellationToken: ct))
.ConfigureAwait(false);
}
// ── Existence check (used before RESTORE ... REPLACE / CREATE DATABASE) ───────────────
public async Task DatabaseExistsAsync(string adminConnectionString,string databaseName,CancellationToken ct)
{
await using var connection =new SqlConnection(adminConnectionString);
await connection.OpenAsync(ct).ConfigureAwait(false);
const string sql = @"SELECT CASE
WHEN EXISTS
(
SELECT 1
FROM sys.databases
WHERE name = @DatabaseName
)
THEN 1
ELSE 0
END;";
var result = await connection.ExecuteScalarAsync(
new CommandDefinition(
sql,
new
{
DatabaseName = databaseName
},
cancellationToken: ct))
.ConfigureAwait(false);
return result == 1;
}
// ── Client-DB schema introspection (Analytics Catalog Wizard) ─────────
public async Task> GetServerColumnsAsync(
string connectionString, byte dbType, CancellationToken ct)
{
switch (dbType)
{
case (byte)DbType.SqlServer:
return await GetSqlServerColumnsAsync(connectionString, ct).ConfigureAwait(false);
default:
throw new NotSupportedException(
$"Client-DB schema introspection for DbType={dbType} is not yet implemented (planned as a follow-up).");
}
}
public async Task> GetServerForeignKeysAsync(
string connectionString, byte dbType, CancellationToken ct)
{
switch (dbType)
{
case (byte)DbType.SqlServer:
return await GetSqlServerForeignKeysAsync(connectionString, ct).ConfigureAwait(false);
default:
throw new NotSupportedException(
$"Client-DB schema introspection for DbType={dbType} is not yet implemented (planned as a follow-up).");
}
}
private async Task> GetSqlServerColumnsAsync(
string connectionString, CancellationToken ct)
{
const string sql = @"
SELECT t.TABLE_SCHEMA AS SchemaName, t.TABLE_NAME AS TableName,
c.COLUMN_NAME AS ColumnName, c.DATA_TYPE AS DataType,
c.CHARACTER_MAXIMUM_LENGTH AS MaxLength, c.NUMERIC_PRECISION AS NumericPrecision,
c.NUMERIC_SCALE AS NumericScale, c.IS_NULLABLE AS IsNullable,
c.ORDINAL_POSITION AS OrdinalPosition,
CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IsPrimaryKey
FROM INFORMATION_SCHEMA.TABLES t
JOIN INFORMATION_SCHEMA.COLUMNS c ON c.TABLE_SCHEMA=t.TABLE_SCHEMA AND c.TABLE_NAME=t.TABLE_NAME
LEFT JOIN (
SELECT ku.TABLE_SCHEMA, ku.TABLE_NAME, ku.COLUMN_NAME
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
ON tc.CONSTRAINT_NAME=ku.CONSTRAINT_NAME AND tc.TABLE_SCHEMA=ku.TABLE_SCHEMA
WHERE tc.CONSTRAINT_TYPE='PRIMARY KEY'
) pk ON pk.TABLE_SCHEMA=t.TABLE_SCHEMA AND pk.TABLE_NAME=t.TABLE_NAME AND pk.COLUMN_NAME=c.COLUMN_NAME
WHERE t.TABLE_TYPE='BASE TABLE'
ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME, c.ORDINAL_POSITION";
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return await conn.QueryAsync(
new CommandDefinition(sql, cancellationToken: ct))
.ConfigureAwait(false);
}
private async Task> GetSqlServerForeignKeysAsync(
string connectionString, CancellationToken ct)
{
const string sql = @"
SELECT fk.name AS ConstraintName,
sch1.name AS FromSchema, tp.name AS FromTable, cp.name AS FromColumn,
sch2.name AS ToSchema, tr.name AS ToTable, cr.name AS ToColumn
FROM sys.foreign_keys fk
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
JOIN sys.tables tp ON tp.object_id = fkc.parent_object_id
JOIN sys.schemas sch1 ON sch1.schema_id = tp.schema_id
JOIN sys.columns cp ON cp.object_id = fkc.parent_object_id AND cp.column_id = fkc.parent_column_id
JOIN sys.tables tr ON tr.object_id = fkc.referenced_object_id
JOIN sys.schemas sch2 ON sch2.schema_id = tr.schema_id
JOIN sys.columns cr ON cr.object_id = fkc.referenced_object_id AND cr.column_id = fkc.referenced_column_id
ORDER BY tp.name, fk.name, fkc.constraint_column_id";
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return await conn.QueryAsync(
new CommandDefinition(sql, cancellationToken: ct))
.ConfigureAwait(false);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static void EnforceSelectOnly(string sql)
{
var trimmed = sql.TrimStart();
if (!_selectOnlyRegex.IsMatch(trimmed))
throw new InvalidOperationException("Only SELECT queries are allowed.");
if (_nonSelectRegex.IsMatch(trimmed))
throw new InvalidOperationException(
"Query contains non-SELECT statements which are not permitted.");
}
}