sozsoft-platform/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpTableCreator.cs
2026-10-07 21:46:45 +03:00

427 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Yeni tabloyu SQL Query Manager'daki tablo tasarimcisiyla ayni sozlesmeyle kurar: script
/// tasarimcinin <c>generateCreateTableSql</c> ciktisiyla ayni bicimdedir ve tasarimcinin deploy
/// sonrasi yazdigi yerlere (<c>sql/object/{Tablo}.sql</c> ve PostgreSQL esi <c>postgres/object/{Tablo}.sql</c>) yazilir; boylece tablo
/// tasarimcida acilip duzenlenebilir ve veritabani sifirlandiginda seed ile geri gelir.
/// <para>
/// Veritabaninda calistirilmaz: tabloyu, wizard deploy'unun ekranini kurdugu gibi kullanicinin
/// calistirdigi DB Migrate kurar (<c>SqlDataSeeder</c>).
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
public interface IMcpTableCreator
{
Task<McpCreatedTable> CreateAsync(McpTableDefinition definition);
}
public class McpTableCreator(
IMcpQueryExecutor queryExecutor,
IConfiguration configuration,
IStringLocalizer<PlatformResource> 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);
/// <summary>
/// Istemcinin SQL bicimiyle yazdigi tip: <c>nvarchar(50)</c>, <c>nvarchar (MAX)</c>,
/// <c>decimal(18, 4)</c>. Boyut tipten ayrilir; tip adi yine beyaz listeden gecer.
/// </summary>
private static readonly Regex SizedTypeRegex = new(
@"^(?<name>[A-Za-z0-9]+)\s*\(\s*(?<size>[^)]*?)\s*\)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>Tasarimci decimal'i her zaman bu kesinlikle yazar (<c>ToColumnLine</c>).</summary>
private const string DecimalSize = "18,4";
/// <summary>
/// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi
/// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez.
/// </summary>
private static readonly Regex SafeDefaultRegex = new(
@"^(?:-?\d{1,18}(?:\.\d{1,18})?|N?'(?:[^']|'')*'|NEWID\(\)|GETDATE\(\)|GETUTCDATE\(\)|SYSUTCDATETIME\(\))$",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
/// <summary>Tasarimcinin tip listesi (<c>DATA_TYPES</c>) ile aynidir.</summary>
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.
/// <summary>Tasarimcinin FK silme davranislari; anahtar istemcinin yazdigi, deger SQL karsiligidir.</summary>
private static readonly Dictionary<string, string> 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<McpCreatedTable> CreateAsync(McpTableDefinition definition)
{
Check.NotNull(definition, nameof(definition));
var tableName = (definition.TableName ?? string.Empty).Trim();
EnsureSafeIdentifier(tableName);
var columns = BuildColumns(definition);
var indexes = BuildIndexes(tableName, definition.Indexes, columns);
var foreignKeys = BuildForeignKeys(definition.ForeignKeys, columns);
var context = await queryExecutor.ResolveDataSourceAsync();
var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql;
async Task<bool> TableExistsAsync(string name)
{
var existing = await context.Repository.ExecuteScalarAsync<object?>(
isPostgreSql ? "SELECT to_regclass(@name)::text" : "SELECT OBJECT_ID(@name, 'U')",
context.ConnectionString,
new Dictionary<string, object> { ["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)
{
throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, tableName]);
}
foreach (var foreignKey in foreignKeys)
await EnsureReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync);
// 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 List<McpTableColumn> 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<McpTableColumn> { 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;
}
/// <summary>Index ve unique kolonlari tablonun kolonlari arasinda olmalidir; ad tasarimcinin kalibindadir.</summary>
private List<McpTableIndex> BuildIndexes(string tableName, IReadOnlyList<McpTableIndex> indexes, List<McpTableColumn> columns)
{
var result = new List<McpTableIndex>();
foreach (var index in indexes)
{
var indexColumns = index.Columns.Select(column => ResolveColumn(column, columns)).Distinct().ToList();
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 List<McpTableForeignKey> BuildForeignKeys(IReadOnlyList<McpTableForeignKey> foreignKeys, List<McpTableColumn> columns)
{
var result = new List<McpTableForeignKey>();
foreach (var foreignKey in foreignKeys)
{
var column = ResolveColumn(foreignKey.Column, columns);
var referencedTable = (foreignKey.ReferencedTable ?? string.Empty).Trim();
var referencedColumn = string.IsNullOrWhiteSpace(foreignKey.ReferencedColumn) ? IdColumn.Name : foreignKey.ReferencedColumn.Trim();
EnsureSafeIdentifier(referencedTable);
EnsureSafeIdentifier(referencedColumn);
// 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;
}
/// <summary>
/// Seed scriptleri DB Migrate'te dosya adi sirasiyla calisir (<c>SqlDataSeeder</c>). 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.
/// </summary>
private async Task EnsureReferenceInstalledFirstAsync(string tableName, McpTableForeignKey foreignKey, Func<string, Task<bool>> tableExistsAsync)
{
var hasScript = PostgreSqlTableScript.ReadSeedColumns(configuration, foreignKey.ReferencedTable).Count > 0;
var installed = hasScript
? Comparer<string>.Default.Compare($"{foreignKey.ReferencedTable}.sql", $"{tableName}.sql") < 0
: await tableExistsAsync(foreignKey.ReferencedTable);
if (!installed)
{
throw new UserFriendlyException(localizer[McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName]);
}
}
private string ResolveColumn(string? name, List<McpTableColumn> columns) =>
columns.FirstOrDefault(column => string.Equals(column.Name, name?.Trim(), StringComparison.OrdinalIgnoreCase))?.Name
?? throw new UserFriendlyException(localizer[McpErrorCodes.TableColumnNotFound, name ?? string.Empty]);
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);
}
/// <summary>
/// Tipi beyaz listedeki adina indirger. <c>nvarchar(N)</c> uzunlugu dondurur,
/// <c>nvarchar(MAX)</c> kendi tipidir; decimal yalnizca tasarimcinin kesinligiyle kabul edilir —
/// baska bir kesinligi 18,4'e cevirmek sessizce veri kaybi riski tasirdi.
/// </summary>
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<McpTableColumn> columns, List<McpTableIndex> indexes, List<McpTableForeignKey> foreignKeys)
{
var fullTableName = $"[{SchemaName}].[{tableName}]";
var lines = new List<string>
{
$"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 void EnsureSafeIdentifier(string identifier)
{
if (!SafeIdentifierRegex.IsMatch(identifier))
{
throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier]);
}
}
}
/// <param name="FullAudited">Tam audit kolonlari (olusturma/degistirme/silme izi) eklensin mi.</param>
/// <param name="MultiTenant"><c>TenantId</c> kolonu eklensin mi.</param>
/// <param name="Indexes">Index ve unique kisitlari; ad sunucuda uretilir (<c>IX_/UQ_{Tablo}_{Kolonlar}</c>).</param>
/// <param name="ForeignKeys">FK'ler; ad <c>FK_{Tablo}_{Kolon}</c>.</param>
public sealed record McpTableDefinition(
string TableName,
IReadOnlyList<McpTableColumn> Columns,
bool FullAudited,
bool MultiTenant,
IReadOnlyList<McpTableIndex> Indexes,
IReadOnlyList<McpTableForeignKey> ForeignKeys);
public sealed record McpTableIndex(IReadOnlyList<string> Columns, bool Unique, string? Name = null);
/// <param name="ReferencedColumn">Bos ise <c>Id</c>.</param>
/// <param name="OnDelete"><c>NoAction</c> (varsayilan), <c>Cascade</c> ya da <c>SetNull</c>.</param>
public sealed record McpTableForeignKey(string Column, string ReferencedTable, string? ReferencedColumn, string? OnDelete);
/// <param name="MaxLength">Yalnizca <c>nvarchar</c> icin; bos ise tasarimcinin varsayilani (100).</param>
public sealed record McpTableColumn(
string Name,
string Type,
int? MaxLength,
bool IsNullable,
string? DefaultValue);
/// <param name="SeedFilePath">Seed kokune gore goreli yol.</param>
public sealed record McpCreatedTable(string TableName, string Script, string SeedFilePath);