using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Localization; using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.Enums; using Sozsoft.Platform.Localization; using Volo.Abp; using Volo.Abp.Domain.Services; namespace Sozsoft.Mcp.Services; /// /// Yeni tabloyu SQL Query Manager'daki tablo tasarimcisiyla ayni sozlesmeyle kurar: script /// tasarimcinin generateCreateTableSql ciktisiyla ayni bicimdedir ve tasarimcinin deploy /// sonrasi yazdigi yere ({kapsam}/sql/object/{Tablo}.sql) yazilir; boylece tablo /// tasarimcida acilip duzenlenebilir ve veritabani sifirlandiginda seed ile geri gelir. /// /// Yalnizca olusturma yapar. Var olan tabloya dokunulmaz — kolon ekleme/degistirme tasarimcinin /// isidir ve sema degisikligi kurallarina (kayipsiz ekleme, degistirme/dusurme yok) tabidir. /// Kolon tipi ve varsayilan deger serbest metin degil, beyaz listeden gecer; istemci SQL yazamaz. /// /// public interface IMcpTableCreator { Task CreateAsync(McpTableDefinition definition); } public class McpTableCreator( IMcpQueryExecutor queryExecutor, IConfiguration configuration, IStringLocalizer localizer) : DomainService, IMcpTableCreator { private const string SchemaName = "dbo"; private const int DefaultTextLength = 100; private const int MaxTextLength = 4000; private static readonly Regex SafeIdentifierRegex = new( @"^[A-Za-z_][A-Za-z0-9_]{0,127}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); /// /// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi /// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez. /// /// /// Istemcinin SQL bicimiyle yazdigi tip: nvarchar(50), nvarchar (MAX), /// decimal(18, 4). Boyut tipten ayrilir; tip adi yine beyaz listeden gecer. /// private static readonly Regex SizedTypeRegex = new( @"^(?[A-Za-z0-9]+)\s*\(\s*(?[^)]*?)\s*\)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); /// Tasarimci decimal'i her zaman bu kesinlikle yazar (ToColumnLine). private const string DecimalSize = "18,4"; private static readonly Regex SafeDefaultRegex = new( @"^(?:-?\d{1,18}(?:\.\d{1,18})?|N?'(?:[^']|'')*'|NEWID\(\)|GETDATE\(\)|GETUTCDATE\(\)|SYSUTCDATETIME\(\))$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); /// Tasarimcinin tip listesi (DATA_TYPES) ile aynidir. private static readonly string[] SupportedTypes = [ "nvarchar", "nvarchar(MAX)", "int", "bigint", "decimal", "float", "bit", "datetime", "datetime2", "date", "uniqueidentifier", "money" ]; // Sablon kolonlari tasarimcinin FULL_AUDIT_COLUMNS / TENANT_COLUMN tanimlariyla aynidir. private static readonly McpTableColumn IdColumn = new("Id", "uniqueidentifier", null, false, "NEWID()"); private static readonly McpTableColumn TenantColumn = new("TenantId", "uniqueidentifier", null, true, null); private static readonly McpTableColumn[] AuditColumns = [ new("CreationTime", "datetime2", null, false, "GETUTCDATE()"), new("CreatorId", "uniqueidentifier", null, true, null), new("LastModificationTime", "datetime2", null, true, null), new("LastModifierId", "uniqueidentifier", null, true, null), new("IsDeleted", "bit", null, false, "0"), new("DeletionTime", "datetime2", null, true, null), new("DeleterId", "uniqueidentifier", null, true, null) ]; public async Task CreateAsync(McpTableDefinition definition) { Check.NotNull(definition, nameof(definition)); var tableName = (definition.TableName ?? string.Empty).Trim(); EnsureSafeIdentifier(tableName); var columns = BuildColumns(definition); var context = await queryExecutor.ResolveDataSourceAsync(); if (context.DataSourceType != DataSourceTypeEnum.Mssql) { throw new UserFriendlyException(localizer[McpErrorCodes.TableProviderNotSupported, context.DataSourceType.ToString()]); } var existing = await context.Repository.ExecuteScalarAsync( "SELECT OBJECT_ID(@name, 'U')", context.ConnectionString, new Dictionary { ["name"] = $"{SchemaName}.{tableName}" }); if (existing is not null) { throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, tableName]); } var script = BuildScript(tableName, columns); // GO yalnizca SSMS ayiricidir; tek batch oldugu icin calistirirken atilir, dosyada kalir. await context.Repository.ExecuteAsync( string.Join('\n', script.Split('\n').Where(line => line.Trim() != "GO")), context.ConnectionString); var seedFilePath = SaveSeedScript(tableName, script); return new McpCreatedTable(tableName, script, seedFilePath); } private List BuildColumns(McpTableDefinition definition) { if (definition.Columns.Count == 0) { throw new UserFriendlyException(localizer[McpErrorCodes.TableColumnsRequired]); } var userColumns = definition.Columns.Select(NormalizeColumn).ToList(); var columns = new List { IdColumn }; if (definition.MultiTenant) { columns.Add(TenantColumn); } columns.AddRange(userColumns); if (definition.FullAudited) { columns.AddRange(AuditColumns); } var duplicate = columns .GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(group => group.Count() > 1); if (duplicate is not null) { throw new UserFriendlyException(localizer[McpErrorCodes.DuplicateColumn, duplicate.Key]); } return columns; } private McpTableColumn NormalizeColumn(McpTableColumn column) { var name = (column.Name ?? string.Empty).Trim(); EnsureSafeIdentifier(name); var (type, sizedLength) = ResolveType(column.Type); int? maxLength = null; if (type == "nvarchar") { maxLength = column.MaxLength ?? sizedLength ?? DefaultTextLength; if (maxLength is < 1 or > MaxTextLength) { throw new UserFriendlyException(localizer[McpErrorCodes.InvalidColumnLength, name, MaxTextLength]); } } var defaultValue = string.IsNullOrWhiteSpace(column.DefaultValue) ? null : column.DefaultValue.Trim(); if (defaultValue is not null && !SafeDefaultRegex.IsMatch(defaultValue)) { throw new UserFriendlyException(localizer[McpErrorCodes.InvalidDefaultValue, name, defaultValue]); } return new McpTableColumn(name, type, maxLength, column.IsNullable, defaultValue); } /// /// Tipi beyaz listedeki adina indirger. nvarchar(N) uzunlugu dondurur, /// nvarchar(MAX) kendi tipidir; decimal yalnizca tasarimcinin kesinligiyle kabul edilir — /// baska bir kesinligi 18,4'e cevirmek sessizce veri kaybi riski tasirdi. /// private (string Type, int? MaxLength) ResolveType(string? rawType) { var trimmed = rawType?.Trim() ?? string.Empty; var name = trimmed; string? size = null; var sized = SizedTypeRegex.Match(trimmed); if (sized.Success) { name = sized.Groups["name"].Value; size = Regex.Replace(sized.Groups["size"].Value, @"\s+", string.Empty); } int? maxLength = null; if (size is not null) { if (name.Equals("nvarchar", StringComparison.OrdinalIgnoreCase) && size.Equals("max", StringComparison.OrdinalIgnoreCase)) { name = "nvarchar(MAX)"; } else if (name.Equals("nvarchar", StringComparison.OrdinalIgnoreCase) && int.TryParse(size, out var length)) { maxLength = length; } else if (!(name.Equals("decimal", StringComparison.OrdinalIgnoreCase) && size == DecimalSize)) { name = trimmed; } } var type = SupportedTypes.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)) ?? throw new UserFriendlyException( localizer[McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes)]); return (type, maxLength); } private static string BuildScript(string tableName, List columns) { var fullTableName = $"[{SchemaName}].[{tableName}]"; var lines = new List { $"IF OBJECT_ID(N'{fullTableName}', 'U') IS NULL", "BEGIN", $" CREATE TABLE {fullTableName}", " (" }; lines.AddRange(columns.Select(column => ToColumnLine(column) + ",")); lines.Add($" CONSTRAINT [PK_{tableName}] PRIMARY KEY NONCLUSTERED"); lines.Add(" ("); lines.Add(" [Id] ASC"); lines.Add(" )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]"); lines.Add(" ) ON [PRIMARY]"); lines.Add("END"); lines.Add("GO"); return string.Join('\n', lines); } private static string ToColumnLine(McpTableColumn column) { var type = column.Type switch { "nvarchar" => $"nvarchar({column.MaxLength})", "decimal" => "decimal(18, 4)", _ => column.Type }; var nullPart = column.IsNullable ? "NULL" : "NOT NULL"; var defaultPart = column.DefaultValue is null ? string.Empty : $" DEFAULT {column.DefaultValue}"; return $" [{column.Name}] {type} {nullPart}{defaultPart}"; } /// /// Tasarimcinin SaveTableScriptAsync ile yazdigi yol. /// Dosyasiz tablo veritabani sifirlandiginda geri gelmezdi; yazilamazsa islem hata verir. /// private string SaveSeedScript(string tableName, string script) { var outputPath = SeedPathResolver.GetPath( configuration, SeedPathResolver.GetProviderFolderName(isPostgreSql: false), SeedPathResolver.ObjectFolder); Directory.CreateDirectory(outputPath); var filePath = Path.Combine(outputPath, $"{tableName}.sql"); File.WriteAllText(filePath, script); return $"{SeedPathResolver.SqlFolder}/{SeedPathResolver.ObjectFolder}/{tableName}.sql"; } private void EnsureSafeIdentifier(string identifier) { if (!SafeIdentifierRegex.IsMatch(identifier)) { throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier]); } } } /// Tam audit kolonlari (olusturma/degistirme/silme izi) eklensin mi. /// TenantId kolonu eklensin mi. public sealed record McpTableDefinition( string TableName, IReadOnlyList Columns, bool FullAudited, bool MultiTenant); /// Yalnizca nvarchar icin; bos ise tasarimcinin varsayilani (100). public sealed record McpTableColumn( string Name, string Type, int? MaxLength, bool IsNullable, string? DefaultValue); /// Seed kokune gore goreli yol. public sealed record McpCreatedTable(string TableName, string Script, string SeedFilePath);