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 yerlere (sql/object/{Tablo}.sql ve PostgreSQL esi postgres/object/{Tablo}.sql) yazilir; boylece tablo /// tasarimcida acilip duzenlenebilir ve veritabani sifirlandiginda seed ile geri gelir. /// /// Veritabaninda calistirilmaz: tabloyu, wizard deploy'unun ekranini kurdugu gibi kullanicinin /// calistirdigi DB Migrate kurar (SqlDataSeeder). /// /// /// 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); /// /// 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"; /// /// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi /// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez. /// 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. /// Tasarimcinin FK silme davranislari; anahtar istemcinin yazdigi, deger SQL karsiligidir. private static readonly Dictionary ReferentialActions = new(StringComparer.OrdinalIgnoreCase) { ["NoAction"] = "NO ACTION", ["Cascade"] = "CASCADE", ["SetNull"] = "SET NULL", }; private const string DefaultReferentialAction = "NoAction"; 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(); if (CheckIdentifier(tableName) is { } invalidName) { return invalidName; } if (!BuildColumns(definition).TryGetValue(out var columns, out var problem) || !BuildIndexes(tableName, definition.Indexes, columns).TryGetValue(out var indexes, out problem) || !BuildForeignKeys(definition.ForeignKeys, columns).TryGetValue(out var foreignKeys, out problem)) { return problem; } var context = await queryExecutor.ResolveDataSourceAsync(); var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql; async Task TableExistsAsync(string name) { var existing = await context.Repository.ExecuteScalarAsync( isPostgreSql ? "SELECT to_regclass(@name)::text" : "SELECT OBJECT_ID(@name, 'U')", context.ConnectionString, new Dictionary { ["name"] = isPostgreSql ? $"public.\"{name}\"" : $"{SchemaName}.{name}" }); return existing is not null and not DBNull; } // Henuz migrate edilmemis bir create_table da tabloyu sahiplenmis sayilir; ikinci cagri onun // scriptinin ustune yazmaz. if (await TableExistsAsync(tableName) || PostgreSqlTableScript.ReadSeedColumns(configuration, tableName).Count > 0) { return Problem(McpErrorCodes.TableAlreadyExists, tableName); } foreach (var foreignKey in foreignKeys) { if (await CheckReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync) is { } notInstalled) { return notInstalled; } } // Dosya biçimi tasarımcınınkiyle aynı T-SQL'dir; PostgreSQL eşi ondan üretilir. var script = BuildScript(tableName, columns, indexes, foreignKeys); if (!PostgreSqlTableScript.TryConvert(script, out _)) { throw new InvalidOperationException($"PostgreSQL script could not be generated for {tableName}."); } // Tablo veritabaninda kurulmaz: wizard deploy'u gibi yalnizca seed yazilir, kurulum kullanicinin // calistirdigi DB Migrate'tedir (SqlDataSeeder). var (seedFilePath, _) = PostgreSqlTableScript.WriteSeedFiles(configuration, tableName, script); return new McpCreatedTable(tableName, script, seedFilePath); } private McpOutcome> BuildColumns(McpTableDefinition definition) { if (definition.Columns.Count == 0) { return Problem(McpErrorCodes.TableColumnsRequired); } var columns = new List { IdColumn }; if (definition.MultiTenant) { columns.Add(TenantColumn); } foreach (var column in definition.Columns) { if (!NormalizeColumn(column).TryGetValue(out var normalized, out var problem)) { return problem; } columns.Add(normalized); } if (definition.FullAudited) { columns.AddRange(AuditColumns); } var duplicate = columns .GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(group => group.Count() > 1); return duplicate is null ? columns : Problem(McpErrorCodes.DuplicateColumn, duplicate.Key); } /// Index ve unique kolonlari tablonun kolonlari arasinda olmalidir; ad tasarimcinin kalibindadir. private McpOutcome> BuildIndexes(string tableName, IReadOnlyList indexes, List columns) { var result = new List(); foreach (var index in indexes) { var indexColumns = new List(); foreach (var column in index.Columns) { if (!ResolveColumn(column, columns).TryGetValue(out var resolved, out var problem)) { return problem; } if (!indexColumns.Contains(resolved)) indexColumns.Add(resolved); } var name = $"{(index.Unique ? "UQ" : "IX")}_{tableName}_{string.Join("_", indexColumns)}"; // Kolonsuz ya da ayni kolonlarla tekrar eden index uretilecek bir sey tasimaz. if (indexColumns.Count > 0 && !result.Exists(existing => string.Equals(existing.Name, name, StringComparison.OrdinalIgnoreCase))) result.Add(new McpTableIndex(indexColumns, index.Unique, name)); } return result; } private McpOutcome> BuildForeignKeys(IReadOnlyList foreignKeys, List columns) { var result = new List(); foreach (var foreignKey in foreignKeys) { if (!ResolveColumn(foreignKey.Column, columns).TryGetValue(out var column, out var problem)) { return problem; } var referencedTable = (foreignKey.ReferencedTable ?? string.Empty).Trim(); var referencedColumn = string.IsNullOrWhiteSpace(foreignKey.ReferencedColumn) ? IdColumn.Name : foreignKey.ReferencedColumn.Trim(); if ((CheckIdentifier(referencedTable) ?? CheckIdentifier(referencedColumn)) is { } invalid) { return invalid; } // Tanimadigi davranis (Restrict gibi) SQL Server'in varsayilanina, NO ACTION'a duser. var action = ReferentialActions.Keys.FirstOrDefault(key => string.Equals(key, foreignKey.OnDelete?.Trim(), StringComparison.OrdinalIgnoreCase)) ?? DefaultReferentialAction; // Bir kolonun tek FK'si olur; ayni kolon icin tekrar eden tanimin ilki gecerlidir. if (!result.Exists(existing => string.Equals(existing.Column, column, StringComparison.OrdinalIgnoreCase))) result.Add(new McpTableForeignKey(column, referencedTable, referencedColumn, action)); } return result; } /// /// Seed scriptleri DB Migrate'te dosya adi sirasiyla calisir (SqlDataSeeder). FK'nin /// gosterdigi tablo bir seed scriptiyle kuruluyorsa o dosya bu tablonunkinden once gelmelidir; /// scripti yoksa (EF ile kurulan platform tablosu) veritabaninda bulunmalidir. Aksi halde /// sifirdan kurulumda migrate FK satirinda duserdi. /// private async Task CheckReferenceInstalledFirstAsync(string tableName, McpTableForeignKey foreignKey, Func> tableExistsAsync) { var hasScript = PostgreSqlTableScript.ReadSeedColumns(configuration, foreignKey.ReferencedTable).Count > 0; var installed = hasScript ? Comparer.Default.Compare($"{foreignKey.ReferencedTable}.sql", $"{tableName}.sql") < 0 : await tableExistsAsync(foreignKey.ReferencedTable); return installed ? null : Problem(McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName); } private McpOutcome ResolveColumn(string? name, List columns) { var match = columns.FirstOrDefault(column => string.Equals(column.Name, name?.Trim(), StringComparison.OrdinalIgnoreCase)); return match is null ? Problem(McpErrorCodes.TableColumnNotFound, name ?? string.Empty) : match.Name; } private McpOutcome NormalizeColumn(McpTableColumn column) { var name = (column.Name ?? string.Empty).Trim(); if (CheckIdentifier(name) is { } invalidName) { return invalidName; } if (!ResolveType(column.Type).TryGetValue(out var resolved, out var problem)) { return problem; } int? maxLength = null; if (resolved.Type == "nvarchar") { maxLength = column.MaxLength ?? resolved.MaxLength ?? DefaultTextLength; if (maxLength is < 1 or > MaxTextLength) { return Problem(McpErrorCodes.InvalidColumnLength, name, MaxTextLength); } } var defaultValue = string.IsNullOrWhiteSpace(column.DefaultValue) ? null : column.DefaultValue.Trim(); if (defaultValue is not null && !SafeDefaultRegex.IsMatch(defaultValue)) { return Problem(McpErrorCodes.InvalidDefaultValue, name, defaultValue); } return new McpTableColumn(name, resolved.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 McpOutcome 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)); return type is null ? Problem(McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes)) : new ResolvedType(type, maxLength); } private sealed record ResolvedType(string Type, int? MaxLength); private static string BuildScript(string tableName, List columns, List indexes, List foreignKeys) { 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"); // Tasarimcinin unique/index/FK deyimleri; her biri varlik kontroluyle yazilir ki migrate // scripti yeniden calistirdiginda dusmesin. foreach (var index in indexes) { var columnList = string.Join(", ", index.Columns.Select(column => $"[{column}] ASC")); lines.Add(string.Empty); if (index.Unique) { lines.Add($"IF OBJECT_ID(N'[{SchemaName}].[{index.Name}]', 'UQ') IS NULL"); lines.Add("BEGIN"); lines.Add($" ALTER TABLE {fullTableName} ADD CONSTRAINT [{index.Name}] UNIQUE NONCLUSTERED ({columnList});"); } else { lines.Add($"IF INDEXPROPERTY(OBJECT_ID(N'{fullTableName}'), '{index.Name}', 'IndexID') IS NULL"); lines.Add("BEGIN"); lines.Add($" CREATE INDEX [{index.Name}] ON {fullTableName} ({columnList});"); } lines.Add("END"); lines.Add("GO"); } foreach (var foreignKey in foreignKeys) { var name = $"FK_{tableName}_{foreignKey.Column}"; lines.Add(string.Empty); lines.Add($"IF OBJECT_ID(N'[{SchemaName}].[{name}]', 'F') IS NULL"); lines.Add("BEGIN"); lines.Add($" ALTER TABLE {fullTableName} ADD CONSTRAINT [{name}] FOREIGN KEY ([{foreignKey.Column}]) " + $"REFERENCES [{SchemaName}].[{foreignKey.ReferencedTable}] ([{foreignKey.ReferencedColumn}]) " + $"ON DELETE {ReferentialActions[foreignKey.OnDelete!]} ON UPDATE NO ACTION;"); 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}"; } private McpProblem? CheckIdentifier(string identifier) => SafeIdentifierRegex.IsMatch(identifier) ? null : Problem(McpErrorCodes.InvalidIdentifier, identifier); private McpProblem Problem(string name, params object[] arguments) => McpProblem.From(localizer, name, arguments); } /// Tam audit kolonlari (olusturma/degistirme/silme izi) eklensin mi. /// TenantId kolonu eklensin mi. /// Index ve unique kisitlari; ad sunucuda uretilir (IX_/UQ_{Tablo}_{Kolonlar}). /// FK'ler; ad FK_{Tablo}_{Kolon}. public sealed record McpTableDefinition( string TableName, IReadOnlyList Columns, bool FullAudited, bool MultiTenant, IReadOnlyList Indexes, IReadOnlyList ForeignKeys); public sealed record McpTableIndex(IReadOnlyList Columns, bool Unique, string? Name = null); /// Bos ise Id. /// NoAction (varsayilan), Cascade ya da SetNull. public sealed record McpTableForeignKey(string Column, string ReferencedTable, string? ReferencedColumn, string? OnDelete); /// 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);