using System.Globalization; using System.Text; using System.Text.RegularExpressions; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Framework.SchemaIntrospection; namespace SwBLL.Provisioning; /// /// Generic, registry-driven replacement for hand-authoring a reference/standard-data DdlScript /// per table (tracker §36.2). Given a source connection + table name + optional row filter, this /// introspects the table's real columns live (via the already-existing /// ITargetDbExecutor.GetServerColumnsAsync, the same schema-browsing path the Analytics Catalog /// Wizard already uses), pulls the matching rows, and emits the same idempotent /// INSERT...SELECT...FROM(VALUES...)...WHERE NOT EXISTS shape proven by hand for /// MCURRENCY/MUOM/MPAYMENTTERM (20260828_StandardRefData_V1_MCurrency_MUom_MPaymentTerm_SqlServer.sql) /// — but without hardcoding a single column name or table shape, so a new table means a new /// caller/registry row, not new code. /// /// TENANTID handling is generic, not a per-table branch: if the introspected column list contains /// a TENANTID column, its value is always emitted as DdlScriptTemplating.TenantIdToken /// ("{{TENANTID}}") rather than copied from the source row — reference tables are commonly /// tenant-scoped (confirmed live for MCURRENCY: every row on a real target carries one fixed /// TENANTID equal to that database's own owning client, not a shared value), and a script /// authored once against one source database must not leak that source's own TenantId into every /// other client database it gets applied to. /// /// A small set of well-known audit/provenance columns are always overridden with fixed values /// rather than copied from the source row, matching the convention already established for every /// hand-authored platform seed script in this repo (see e.g. /// 20260716_Entitlement_Phase2_5_PasswordResetMailTemplate_Seed_SqlServer.sql's own header: /// "SOURCETYPE = 1 (Framework) -- this row is migration-seeded platform configuration") — a /// generated script is itself a fresh platform delivery, not a historical record of who created /// the source's own copy of the row. /// public interface IReferenceDataScriptGenerator { Task GenerateSeedScriptAsync( string sourceConnectionString, byte sourceDbType, string tableName, string? rowFilterSql, CancellationToken ct); } public class ReferenceDataScriptGenerator : IReferenceDataScriptGenerator { private readonly ITargetDbExecutor _targetDbExecutor; public ReferenceDataScriptGenerator(ITargetDbExecutor targetDbExecutor) => _targetDbExecutor = targetDbExecutor; private static readonly IReadOnlyDictionary OverrideColumns = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["CREATEDBYID"] = "-1", ["CREATEDON"] = "GETDATE()", ["MODIFIEDBYID"] = "-1", ["MODIFIEDON"] = "GETDATE()", ["SOURCETYPE"] = "1", // Framework — this batch is itself a fresh platform delivery ["VERSION"] = "0", // optimistic-concurrency counter for a newly-delivered row — // copying the source's own arbitrary historical edit-count // (confirmed live: GB5DEMO's real MCURRENCY rows carry // VERSION values like 2/3/4) would be meaningless here. }; private const string TenantIdColumn = "TENANTID"; private static readonly HashSet NumericTypes = new(StringComparer.OrdinalIgnoreCase) { "int", "tinyint", "smallint", "bigint", "decimal", "numeric", "float", "real", "bit", "money", "smallmoney" }; private static readonly HashSet DateTypes = new(StringComparer.OrdinalIgnoreCase) { "datetime", "datetime2", "smalldatetime", "date" }; // Same discipline as SyncQueryBuilder's QUERYCONDITION guard — a caller-supplied row filter // is registry-authored configuration, not end-user input, but gets the identical defense-in- // depth treatment since its output ultimately becomes governed script content. private static readonly Regex DisallowedKeywords = new(@"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|DENY|UNION|SELECT|INTO|FROM)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); public async Task GenerateSeedScriptAsync( string sourceConnectionString, byte sourceDbType, string tableName, string? rowFilterSql, CancellationToken ct) { if (string.IsNullOrWhiteSpace(tableName)) throw new ArgumentException("TableName is required.", nameof(tableName)); if (!string.IsNullOrWhiteSpace(rowFilterSql) && DisallowedKeywords.IsMatch(rowFilterSql)) throw new InvalidOperationException("Row filter contains disallowed keywords (DML/DDL/SELECT/UNION)."); var allColumns = await _targetDbExecutor.GetServerColumnsAsync(sourceConnectionString, sourceDbType, ct) .ConfigureAwait(false); var columns = allColumns .Where(c => c.TableName.Equals(tableName, StringComparison.OrdinalIgnoreCase)) .OrderBy(c => c.OrdinalPosition) .ToList(); if (columns.Count == 0) throw new InvalidOperationException($"Table '{tableName}' has no columns (not found, or introspection returned nothing)."); var pkColumn = columns.FirstOrDefault(c => c.IsPrimaryKey)?.ColumnName ?? throw new InvalidOperationException($"Table '{tableName}' has no single-column primary key — not supported by this generator."); bool hasTenantId = columns.Any(c => c.ColumnName.Equals(TenantIdColumn, StringComparison.OrdinalIgnoreCase)); string whereClause = string.IsNullOrWhiteSpace(rowFilterSql) ? string.Empty : $"WHERE {rowFilterSql}"; string selectSql = $"SELECT * FROM {tableName} {whereClause}".Trim(); var rows = await _targetDbExecutor.ExecuteQueryAsync(sourceConnectionString, selectSql, [], ct) .ConfigureAwait(false); var rowList = rows.ToList(); if (rowList.Count == 0) throw new InvalidOperationException($"No rows matched for table '{tableName}' with the given filter — nothing to generate."); var columnNames = columns.Select(c => c.ColumnName).ToList(); var valuesRows = rowList.Select(row => " (" + string.Join(", ", columns.Select(c => FormatColumnValue(c, row.TryGetValue(c.ColumnName, out var v) ? v : null, hasTenantId))) + ")"); var columnListSql = string.Join(", ", columnNames); var selectListSql = string.Join(", ", columnNames.Select(c => $"v.{c}")); var sb = new StringBuilder(); sb.AppendLine($"INSERT INTO {tableName}"); sb.AppendLine($" ({columnListSql})"); sb.AppendLine($"SELECT {selectListSql}"); sb.AppendLine($"FROM (VALUES"); sb.AppendLine(string.Join(",\n", valuesRows)); sb.AppendLine($") AS v({columnListSql})"); sb.AppendLine($"WHERE NOT EXISTS (SELECT 1 FROM {tableName} x WHERE x.{pkColumn} = v.{pkColumn});"); return sb.ToString(); } private static string FormatColumnValue(SchemaTableColumnDTO column, object? value, bool hasTenantId) { if (column.ColumnName.Equals(TenantIdColumn, StringComparison.OrdinalIgnoreCase)) return DdlScriptTemplating.TenantIdToken; if (OverrideColumns.TryGetValue(column.ColumnName, out var overrideLiteral)) return overrideLiteral; if (value is null || value is DBNull) return "NULL"; if (NumericTypes.Contains(column.DataType)) return Convert.ToString(value, CultureInfo.InvariantCulture) ?? "NULL"; if (DateTypes.Contains(column.DataType)) return $"'{Convert.ToDateTime(value, CultureInfo.InvariantCulture):yyyy-MM-dd HH:mm:ss}'"; return $"N'{value.ToString()!.Replace("'", "''")}'"; } }