using System.Text;
using System.Text.RegularExpressions;
using SwDAL.DTO.MetadataSync;
namespace SwBLL.MetadataSync;
///
/// Builds the DML script attached to a Draft ChangeRequest from a metadata version's tracked
/// changes. Extracted from MetadataSyncBLL for testability and to fix a real injection risk:
/// TableName/ColumnName arrive as free text via SaveMetadataChange (client-editable), and were
/// previously interpolated into DDL/DML text with no validation at all — only NewValue/OldValue
/// were escaped (naively, via Replace("'", "''")).
///
/// This is dynamic multi-statement SQL script text destined for ITargetDbExecutor (executed
/// against an external target database, not GB5's own DB via IQueryExecutor) — Dapper-style
/// parameterization does not apply to identifiers (table/column names can never be SQL
/// parameters in any dialect). The defense here is structural: TableName/ColumnName must match
/// a strict identifier pattern before they are ever interpolated, so no injection payload
/// (quotes, semicolons, comment markers, keywords) can survive validation regardless of its
/// content.
///
public static class MetadataSyncSqlBuilder
{
// Schema-qualified SQL identifier: letters/digits/underscore, optionally one "schema.table"
// dot-separated segment. Matches SQL Server/Postgres/MySQL identifier rules across the
// multi-DBMS targets this module is heading toward — deliberately conservative (no quoted
// identifiers, no brackets) since nothing legitimate in this codebase's own schema needs them.
private static readonly Regex ValidIdentifier =
new(@"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$", RegexOptions.Compiled);
public static string BuildDmlFromChanges(string versionLabel, IList changes)
{
var sb = new StringBuilder();
sb.AppendLine($"-- MetadataSync: {versionLabel}");
sb.AppendLine($"-- Generated on: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine();
foreach (var c in changes)
{
var tableName = RequireValidIdentifier(c.TableName, nameof(c.TableName), c);
var columnName = RequireValidIdentifier(c.ColumnName, nameof(c.ColumnName), c);
var type = (c.ChangeType ?? string.Empty).Trim().ToUpperInvariant();
var safeNew = QuoteLiteral(c.NewValue);
var safeOld = QuoteLiteral(c.OldValue);
switch (type)
{
case "INSERT":
sb.AppendLine($"INSERT INTO {tableName} ({columnName}) VALUES ({safeNew});");
break;
case "UPDATE":
sb.AppendLine($"UPDATE {tableName} SET {columnName} = {safeNew} WHERE {columnName} = {safeOld};");
break;
case "DELETE":
sb.AppendLine($"DELETE FROM {tableName} WHERE {columnName} = {safeOld};");
break;
default:
sb.AppendLine($"-- {tableName}.{columnName}: {c.ChangeType} (OldValue={safeOld}, NewValue={safeNew})");
break;
}
}
return sb.ToString();
}
///
/// Rejects anything that isn't a plain (optionally schema-qualified) SQL identifier —
/// the only structural guarantee available without a live schema-catalog check.
///
private static string RequireValidIdentifier(string? value, string fieldName, MetadataChangeDTO change)
{
if (string.IsNullOrWhiteSpace(value) || !ValidIdentifier.IsMatch(value))
throw new InvalidOperationException(
$"MetadataChange {change.MetaVersionId}: {fieldName} '{value}' is not a valid SQL identifier — refusing to generate DML.");
return value;
}
///
/// Standard SQL string-literal escaping (single-quote doubling) applied to a value already
/// wrapped in quotes — NULL maps to the SQL literal NULL, not the string "NULL".
///
private static string QuoteLiteral(string? value)
=> value is null ? "NULL" : $"'{value.Replace("'", "''")}'";
}