296 lines
12 KiB
C#
296 lines
12 KiB
C#
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>
|
||
/// 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>
|
||
/// Varsayilan deger olarak kabul edilenler: sayi, metin sabiti ve tasarimcinin kendi
|
||
/// sablonlarinda kullandigi fonksiyonlar. Baska bir ifade script'e hic girmez.
|
||
/// </summary>
|
||
/// <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";
|
||
|
||
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.
|
||
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 context = await queryExecutor.ResolveDataSourceAsync();
|
||
var isPostgreSql = context.DataSourceType == DataSourceTypeEnum.Postgresql;
|
||
|
||
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.\"{tableName}\"" : $"{SchemaName}.{tableName}" });
|
||
|
||
if (existing is not null and not DBNull)
|
||
{
|
||
throw new UserFriendlyException(localizer[McpErrorCodes.TableAlreadyExists, tableName]);
|
||
}
|
||
|
||
// Dosya biçimi tasarımcınınkiyle aynı T-SQL'dir; PostgreSQL eşi ondan üretilir ve PG veri
|
||
// kaynağında çalıştırılan da o eştir.
|
||
var script = BuildScript(tableName, columns);
|
||
if (!PostgreSqlTableScript.TryConvert(script, out var postgreSqlScript))
|
||
{
|
||
throw new InvalidOperationException($"PostgreSQL script could not be generated for {tableName}.");
|
||
}
|
||
|
||
// GO yalnizca SSMS ayiricidir; tek batch oldugu icin calistirirken atilir, dosyada kalir.
|
||
await context.Repository.ExecuteAsync(
|
||
isPostgreSql ? postgreSqlScript : string.Join('\n', script.Split('\n').Where(line => line.Trim() != "GO")),
|
||
context.ConnectionString);
|
||
|
||
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;
|
||
}
|
||
|
||
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)
|
||
{
|
||
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");
|
||
|
||
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>
|
||
public sealed record McpTableDefinition(
|
||
string TableName,
|
||
IReadOnlyList<McpTableColumn> Columns,
|
||
bool FullAudited,
|
||
bool MultiTenant);
|
||
|
||
/// <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);
|