2026-10-10 22:03:36 +00:00
|
|
|
|
using System;
|
2026-09-18 13:47:23 +00:00
|
|
|
|
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;
|
2026-10-17 18:51:10 +00:00
|
|
|
|
using Sozsoft.Platform.Data;
|
2026-09-18 13:47:23 +00:00
|
|
|
|
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
|
2026-10-06 11:24:30 +00:00
|
|
|
|
/// sonrasi yazdigi yerlere (<c>sql/object/{Tablo}.sql</c> ve PostgreSQL esi <c>postgres/object/{Tablo}.sql</c>) yazilir; boylece tablo
|
2026-09-18 13:47:23 +00:00
|
|
|
|
/// tasarimcida acilip duzenlenebilir ve veritabani sifirlandiginda seed ile geri gelir.
|
|
|
|
|
|
/// <para>
|
2026-10-07 12:45:18 +00:00
|
|
|
|
/// Veritabaninda calistirilmaz: tabloyu, wizard deploy'unun ekranini kurdugu gibi kullanicinin
|
|
|
|
|
|
/// calistirdigi DB Migrate kurar (<c>SqlDataSeeder</c>).
|
|
|
|
|
|
/// </para>
|
|
|
|
|
|
/// <para>
|
2026-09-18 13:47:23 +00:00
|
|
|
|
/// 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
|
|
|
|
|
|
{
|
2026-10-10 22:03:36 +00:00
|
|
|
|
Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-09-30 14:57:25 +00:00
|
|
|
|
/// <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";
|
|
|
|
|
|
|
2026-10-07 18:46:45 +00:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi
|
|
|
|
|
|
/// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez.
|
|
|
|
|
|
/// </summary>
|
2026-09-18 13:47:23 +00:00
|
|
|
|
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.
|
2026-10-07 18:46:45 +00:00
|
|
|
|
/// <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";
|
|
|
|
|
|
|
2026-09-18 13:47:23 +00:00
|
|
|
|
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)
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
public async Task<McpOutcome<McpCreatedTable>> CreateAsync(McpTableDefinition definition)
|
2026-09-18 13:47:23 +00:00
|
|
|
|
{
|
|
|
|
|
|
Check.NotNull(definition, nameof(definition));
|
|
|
|
|
|
|
|
|
|
|
|
var tableName = (definition.TableName ?? string.Empty).Trim();
|
2026-10-10 22:03:36 +00:00
|
|
|
|
if (CheckIdentifier(tableName) is { } invalidName)
|
|
|
|
|
|
{
|
|
|
|
|
|
return invalidName;
|
|
|
|
|
|
}
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
|
|
|
|
|
var context = await queryExecutor.ResolveDataSourceAsync();
|
2026-10-06 11:24:30 +00:00
|
|
|
|
var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql;
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
2026-10-07 18:46:45 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
2026-10-07 12:45:18 +00:00
|
|
|
|
// Henuz migrate edilmemis bir create_table da tabloyu sahiplenmis sayilir; ikinci cagri onun
|
|
|
|
|
|
// scriptinin ustune yazmaz.
|
2026-10-07 18:46:45 +00:00
|
|
|
|
if (await TableExistsAsync(tableName) || PostgreSqlTableScript.ReadSeedColumns(configuration, tableName).Count > 0)
|
2026-09-18 13:47:23 +00:00
|
|
|
|
{
|
2026-10-10 22:03:36 +00:00
|
|
|
|
return Problem(McpErrorCodes.TableAlreadyExists, tableName);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:46:45 +00:00
|
|
|
|
foreach (var foreignKey in foreignKeys)
|
2026-10-10 22:03:36 +00:00
|
|
|
|
{
|
|
|
|
|
|
if (await CheckReferenceInstalledFirstAsync(tableName, foreignKey, TableExistsAsync) is { } notInstalled)
|
|
|
|
|
|
{
|
|
|
|
|
|
return notInstalled;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-07 18:46:45 +00:00
|
|
|
|
|
2026-10-07 12:45:18 +00:00
|
|
|
|
// Dosya biçimi tasarımcınınkiyle aynı T-SQL'dir; PostgreSQL eşi ondan üretilir.
|
2026-10-07 18:46:45 +00:00
|
|
|
|
var script = BuildScript(tableName, columns, indexes, foreignKeys);
|
2026-10-07 12:45:18 +00:00
|
|
|
|
if (!PostgreSqlTableScript.TryConvert(script, out _))
|
2026-10-06 11:24:30 +00:00
|
|
|
|
{
|
|
|
|
|
|
throw new InvalidOperationException($"PostgreSQL script could not be generated for {tableName}.");
|
|
|
|
|
|
}
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
2026-10-07 12:45:18 +00:00
|
|
|
|
// Tablo veritabaninda kurulmaz: wizard deploy'u gibi yalnizca seed yazilir, kurulum kullanicinin
|
|
|
|
|
|
// calistirdigi DB Migrate'tedir (SqlDataSeeder).
|
2026-10-06 11:24:30 +00:00
|
|
|
|
var (seedFilePath, _) = PostgreSqlTableScript.WriteSeedFiles(configuration, tableName, script);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
|
|
|
|
|
return new McpCreatedTable(tableName, script, seedFilePath);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
private McpOutcome<List<McpTableColumn>> BuildColumns(McpTableDefinition definition)
|
2026-09-18 13:47:23 +00:00
|
|
|
|
{
|
|
|
|
|
|
if (definition.Columns.Count == 0)
|
|
|
|
|
|
{
|
2026-10-10 22:03:36 +00:00
|
|
|
|
return Problem(McpErrorCodes.TableColumnsRequired);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var columns = new List<McpTableColumn> { IdColumn };
|
|
|
|
|
|
if (definition.MultiTenant)
|
|
|
|
|
|
{
|
|
|
|
|
|
columns.Add(TenantColumn);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
foreach (var column in definition.Columns)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!NormalizeColumn(column).TryGetValue(out var normalized, out var problem))
|
|
|
|
|
|
{
|
|
|
|
|
|
return problem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
columns.Add(normalized);
|
|
|
|
|
|
}
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
|
|
|
|
|
if (definition.FullAudited)
|
|
|
|
|
|
{
|
|
|
|
|
|
columns.AddRange(AuditColumns);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var duplicate = columns
|
|
|
|
|
|
.GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
|
.FirstOrDefault(group => group.Count() > 1);
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
return duplicate is null ? columns : Problem(McpErrorCodes.DuplicateColumn, duplicate.Key);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:46:45 +00:00
|
|
|
|
/// <summary>Index ve unique kolonlari tablonun kolonlari arasinda olmalidir; ad tasarimcinin kalibindadir.</summary>
|
2026-10-10 22:03:36 +00:00
|
|
|
|
private McpOutcome<List<McpTableIndex>> BuildIndexes(string tableName, IReadOnlyList<McpTableIndex> indexes, List<McpTableColumn> columns)
|
2026-10-07 18:46:45 +00:00
|
|
|
|
{
|
|
|
|
|
|
var result = new List<McpTableIndex>();
|
|
|
|
|
|
foreach (var index in indexes)
|
|
|
|
|
|
{
|
2026-10-10 22:03:36 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:46:45 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
private McpOutcome<List<McpTableForeignKey>> BuildForeignKeys(IReadOnlyList<McpTableForeignKey> foreignKeys, List<McpTableColumn> columns)
|
2026-10-07 18:46:45 +00:00
|
|
|
|
{
|
|
|
|
|
|
var result = new List<McpTableForeignKey>();
|
|
|
|
|
|
foreach (var foreignKey in foreignKeys)
|
|
|
|
|
|
{
|
2026-10-10 22:03:36 +00:00
|
|
|
|
if (!ResolveColumn(foreignKey.Column, columns).TryGetValue(out var column, out var problem))
|
|
|
|
|
|
{
|
|
|
|
|
|
return problem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 18:46:45 +00:00
|
|
|
|
var referencedTable = (foreignKey.ReferencedTable ?? string.Empty).Trim();
|
|
|
|
|
|
var referencedColumn = string.IsNullOrWhiteSpace(foreignKey.ReferencedColumn) ? IdColumn.Name : foreignKey.ReferencedColumn.Trim();
|
2026-10-10 22:03:36 +00:00
|
|
|
|
if ((CheckIdentifier(referencedTable) ?? CheckIdentifier(referencedColumn)) is { } invalid)
|
|
|
|
|
|
{
|
|
|
|
|
|
return invalid;
|
|
|
|
|
|
}
|
2026-10-07 18:46:45 +00:00
|
|
|
|
|
|
|
|
|
|
// 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>
|
2026-10-10 22:03:36 +00:00
|
|
|
|
private async Task<McpProblem?> CheckReferenceInstalledFirstAsync(string tableName, McpTableForeignKey foreignKey, Func<string, Task<bool>> tableExistsAsync)
|
2026-10-07 18:46:45 +00:00
|
|
|
|
{
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
return installed
|
|
|
|
|
|
? null
|
|
|
|
|
|
: Problem(McpErrorCodes.ForeignKeyTableNotInstalledFirst, foreignKey.Column, foreignKey.ReferencedTable, tableName);
|
2026-10-07 18:46:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-10-07 18:46:45 +00:00
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
private McpOutcome<McpTableColumn> NormalizeColumn(McpTableColumn column)
|
2026-09-18 13:47:23 +00:00
|
|
|
|
{
|
|
|
|
|
|
var name = (column.Name ?? string.Empty).Trim();
|
2026-10-10 22:03:36 +00:00
|
|
|
|
if (CheckIdentifier(name) is { } invalidName)
|
|
|
|
|
|
{
|
|
|
|
|
|
return invalidName;
|
|
|
|
|
|
}
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
if (!ResolveType(column.Type).TryGetValue(out var resolved, out var problem))
|
|
|
|
|
|
{
|
|
|
|
|
|
return problem;
|
|
|
|
|
|
}
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
|
|
|
|
|
int? maxLength = null;
|
2026-10-10 22:03:36 +00:00
|
|
|
|
if (resolved.Type == "nvarchar")
|
2026-09-18 13:47:23 +00:00
|
|
|
|
{
|
2026-10-10 22:03:36 +00:00
|
|
|
|
maxLength = column.MaxLength ?? resolved.MaxLength ?? DefaultTextLength;
|
2026-09-18 13:47:23 +00:00
|
|
|
|
if (maxLength is < 1 or > MaxTextLength)
|
|
|
|
|
|
{
|
2026-10-10 22:03:36 +00:00
|
|
|
|
return Problem(McpErrorCodes.InvalidColumnLength, name, MaxTextLength);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var defaultValue = string.IsNullOrWhiteSpace(column.DefaultValue) ? null : column.DefaultValue.Trim();
|
|
|
|
|
|
if (defaultValue is not null && !SafeDefaultRegex.IsMatch(defaultValue))
|
|
|
|
|
|
{
|
2026-10-10 22:03:36 +00:00
|
|
|
|
return Problem(McpErrorCodes.InvalidDefaultValue, name, defaultValue);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
return new McpTableColumn(name, resolved.Type, maxLength, column.IsNullable, defaultValue);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 14:57:25 +00:00
|
|
|
|
/// <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>
|
2026-10-10 22:03:36 +00:00
|
|
|
|
private McpOutcome<ResolvedType> ResolveType(string? rawType)
|
2026-09-30 14:57:25 +00:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
var type = SupportedTypes.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase));
|
2026-09-30 14:57:25 +00:00
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
return type is null
|
|
|
|
|
|
? Problem(McpErrorCodes.UnsupportedColumnType, trimmed, string.Join(", ", SupportedTypes))
|
|
|
|
|
|
: new ResolvedType(type, maxLength);
|
2026-09-30 14:57:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
private sealed record ResolvedType(string Type, int? MaxLength);
|
|
|
|
|
|
|
2026-10-07 18:46:45 +00:00
|
|
|
|
private static string BuildScript(string tableName, List<McpTableColumn> columns, List<McpTableIndex> indexes, List<McpTableForeignKey> foreignKeys)
|
2026-09-18 13:47:23 +00:00
|
|
|
|
{
|
|
|
|
|
|
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");
|
|
|
|
|
|
|
2026-10-07 18:46:45 +00:00
|
|
|
|
// 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 13:47:23 +00:00
|
|
|
|
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}";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 22:03:36 +00:00
|
|
|
|
private McpProblem? CheckIdentifier(string identifier)
|
2026-10-17 18:51:10 +00:00
|
|
|
|
=> SqlIdentifier.IsSafe(identifier) ? null : Problem(McpErrorCodes.InvalidIdentifier, identifier);
|
2026-10-10 22:03:36 +00:00
|
|
|
|
|
|
|
|
|
|
private McpProblem Problem(string name, params object[] arguments)
|
|
|
|
|
|
=> McpProblem.From(localizer, name, arguments);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <param name="FullAudited">Tam audit kolonlari (olusturma/degistirme/silme izi) eklensin mi.</param>
|
|
|
|
|
|
/// <param name="MultiTenant"><c>TenantId</c> kolonu eklensin mi.</param>
|
2026-10-07 18:46:45 +00:00
|
|
|
|
/// <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>
|
2026-09-18 13:47:23 +00:00
|
|
|
|
public sealed record McpTableDefinition(
|
|
|
|
|
|
string TableName,
|
|
|
|
|
|
IReadOnlyList<McpTableColumn> Columns,
|
|
|
|
|
|
bool FullAudited,
|
2026-10-07 18:46:45 +00:00
|
|
|
|
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);
|
2026-09-18 13:47:23 +00:00
|
|
|
|
|
|
|
|
|
|
/// <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);
|