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

460 lines
20 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;
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<McpOutcome<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;
/// <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<McpOutcome<McpCreatedTable>> 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<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)
{
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<List<McpTableColumn>> BuildColumns(McpTableDefinition definition)
{
if (definition.Columns.Count == 0)
{
return Problem(McpErrorCodes.TableColumnsRequired);
}
var columns = new List<McpTableColumn> { 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);
}
/// <summary>Index ve unique kolonlari tablonun kolonlari arasinda olmalidir; ad tasarimcinin kalibindadir.</summary>
private McpOutcome<List<McpTableIndex>> BuildIndexes(string tableName, IReadOnlyList<McpTableIndex> indexes, List<McpTableColumn> columns)
{
var result = new List<McpTableIndex>();
foreach (var index in indexes)
{
var indexColumns = new List<string>();
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<List<McpTableForeignKey>> BuildForeignKeys(IReadOnlyList<McpTableForeignKey> foreignKeys, List<McpTableColumn> columns)
{
var result = new List<McpTableForeignKey>();
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;
}
/// <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<McpProblem?> CheckReferenceInstalledFirstAsync(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);
return installed
? null
: Problem(McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName);
}
private McpOutcome<string> ResolveColumn(string? name, List<McpTableColumn> 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<McpTableColumn> 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);
}
/// <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 McpOutcome<ResolvedType> 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<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 McpProblem? CheckIdentifier(string identifier)
=> SqlIdentifier.IsSafe(identifier) ? null : Problem(McpErrorCodes.InvalidIdentifier, identifier);
private McpProblem Problem(string name, params object[] arguments)
=> McpProblem.From(localizer, name, arguments);
}
/// <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);