WizardFileManager CustomEndpoints silme prosedürüne alındı

This commit is contained in:
Sedat ÖZTÜRK 2026-10-07 16:53:08 +03:00
parent 02b8d3abb4
commit 135a85e682
10 changed files with 273 additions and 10 deletions

View file

@ -1986,6 +1986,11 @@ işlemde siler. Onay diyaloğu sırayı ve tabloları gösterir:
- **Tablolar en sonda** düşürülür: bütün ekranlar gittikten sonra kullanım yeniden ölçülür; aynı
tabloyu paylaşan alt formlar (`Hr_T_HealthCard` gibi) tabloyu bir kez listeler. Bağlantılar veri
kaynağı kaydı silinmeden önce çözülür. Tablo seçmek yine `App.SqlQueryManager` ister.
- **Talebin Custom Endpoint'leri de gider:** tablo talebe aitse (`create_table`/tasarımcı scripti var,
talep dışında ekran kullanmıyor) onu sorgulayan Custom Endpoint'ler tabloyu tutmaz; veritabanından
ve `data/App.DeveloperKit.CustomEndpoints.json`'dan (migrate edilmemiş satır dahil) silinir —
tablo seçilmese de (`WizardDeleteTableDto.CustomEndpoints`, `WizardTableCleaner.DeleteCustomEndpointsAsync`).
Diyalog bunları tablonun altında listeler. Tek wizard silmede endpoint yine tabloyu "kullanımda" tutar.
- Rapor tek wizard silmeyle aynı sözleşmedir (`WizardDeleteResultDto`, bütün wizard'ların kayıtları
tek listede).

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Sozsoft.Mcp.Dtos;
@ -30,7 +31,7 @@ namespace Sozsoft.Mcp;
/// <see cref="IMcpQueryExecutor"/> icindedir.
/// </summary>
[Authorize(PlatformConsts.AppCodes.DeveloperKits.McpServers.Invoke)]
public class McpQueryAppService(
public partial class McpQueryAppService(
IMcpServerConfigProvider serverConfigProvider,
IMcpSqlGuard sqlGuard,
IMcpQueryExecutor queryExecutor,
@ -115,10 +116,67 @@ public class McpQueryAppService(
}
catch (DbException ex)
{
throw new UserFriendlyException(L[McpErrorCodes.SelectFailed, ex.Message]);
var columns = IsUnknownColumnError(ex) ? await DescribeTableColumnsAsync(context, sql) : null;
throw new UserFriendlyException(columns is null
? L[McpErrorCodes.SelectFailed, ex.Message]
: L[McpErrorCodes.SelectFailedColumns, ex.Message, columns]);
}
}
private const int MaxDescribedTables = 5;
/// <summary>SQL Server 207 ("Invalid column name"), PostgreSQL 42703 (undefined_column).</summary>
private static bool IsUnknownColumnError(DbException ex) =>
ex.SqlState == "42703" || ex.Message.Contains("Invalid column name", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Sorgunun FROM/JOIN ile andigi tablolarin gercek kolonlari (<c>Tablo(A, B, C); …</c>). Model
/// kolonu tahmin ettiyse dogru listeyi ayni cevapta gorur ve ayrica sema araci cagirmadan duzeltir.
/// Kolonlar okunamazsa null doner; asil hata yine iletilir.
/// </summary>
private async Task<string?> DescribeTableColumnsAsync(McpDataSourceContext context, string sql)
{
var tables = TableReferenceRegex().Matches(sql)
.Select(match => match.Groups["table"].Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxDescribedTables)
.ToList();
if (tables.Count == 0)
return null;
var parameters = tables.Select((table, index) => (Name: $"t{index}", Table: table))
.ToDictionary(p => p.Name, p => (object)p.Table);
try
{
var rows = await queryExecutor.ExecuteAsync(
context,
$"SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME IN ({string.Join(", ", parameters.Keys.Select(name => "@" + name))}) ORDER BY TABLE_NAME, ORDINAL_POSITION",
parameters,
int.MaxValue);
var described = rows.Rows
.Select(row => (Table: ReadIgnoreCase(row, "TABLE_NAME"), Column: ReadIgnoreCase(row, "COLUMN_NAME")))
.Where(item => item.Table is not null && item.Column is not null)
.GroupBy(item => item.Table!, StringComparer.OrdinalIgnoreCase)
.Select(group => $"{group.Key}({string.Join(", ", group.Select(item => item.Column))})")
.ToList();
return described.Count == 0 ? null : string.Join("; ", described);
}
catch (DbException)
{
return null;
}
}
private static string? ReadIgnoreCase(Dictionary<string, object?> row, string key) =>
row.FirstOrDefault(pair => string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
/// <summary>FROM/JOIN'den sonraki ad; sema ve kose parantezi/tirnak atilir (<c>[dbo].[Tablo]</c> → <c>Tablo</c>).</summary>
[GeneratedRegex(@"\b(?:FROM|JOIN)\s+(?:[\[""]?\w+[\]""]?\.)?[\[""]?(?<table>\w+)[\]""]?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex TableReferenceRegex();
public async Task<List<McpSavedQueryDto>> GetSavedQueriesAsync(string? search = null)
{
await EnsureToolAllowedAsync(McpToolNames.ListQueries);

View file

@ -48,6 +48,7 @@ public static class McpErrorCodes
public const string VerifySelectPassed = Prefix + "VerifySelectPassed";
public const string VerifySelectFailed = Prefix + "VerifySelectFailed";
public const string SelectFailed = Prefix + "SelectFailed";
public const string SelectFailedColumns = Prefix + "SelectFailedColumns";
public const string VerifyComponentInactive = Prefix + "VerifyComponentInactive";
public const string VerifyComponentRouteMismatch = Prefix + "VerifyComponentRouteMismatch";
public const string VerifySeedClosureIncomplete = Prefix + "VerifySeedClosureIncomplete";

View file

@ -513,6 +513,12 @@ public class WizardDeleteTableDto
/// <summary>Tabloyla birlikte silinecek seed dosyalari, seed klasorune gore goreli.</summary>
public List<string> Files { get; set; } = [];
/// <summary>
/// Talep silmesinde talepten kalkan Custom Endpoint'ler (ad): tablo talebe aittir (scripti var,
/// talep disinda ekran kullanmiyor) ve endpoint onu sorgular. Tablo secilmese de silinir.
/// </summary>
public List<string> CustomEndpoints { get; set; } = [];
}
/// <summary>Silmeden once onay diyaloguna gelen analiz.</summary>

View file

@ -315,6 +315,8 @@ public class ListFormWizardAppService(
: tableColumns.ContainsKey("IsDeleted");
isCreated = tableColumns.ContainsKey("CreatorId");
ValidateImageColumnLengths(input, tableColumns);
foreach (var item in input.Groups.SelectMany(g => g.Items))
item.CaptionName = await ResolveLanguageKeyCasingAsync(rows, item.CaptionName);
listFormDto = input.BuildListForm(isDeleted, isCreated);
fieldDtos = input.BuildFields();
@ -429,6 +431,24 @@ public class ListFormWizardAppService(
inserted.LanguageKeys.Add(key);
}
/// <summary>
/// Anahtar yalnizca harf buyuklugu farkiyla zaten varsa onun yazimini dondurur. Anahtar
/// <c>(ResourceName, Key)</c> uzerinde harf duyarsiz tekildir, metin sozlugu ise harf duyarlidir:
/// farkli yazimla birakilan alan basligi ikinci bir anahtar acamaz ve ekranda anahtar olarak kalir.
/// </summary>
private async Task<string?> ResolveLanguageKeyCasingAsync(WizardSeedRowStore rows, string? key)
{
if (string.IsNullOrWhiteSpace(key))
return key;
var fileKey = WizardSeedRowStore.Text(rows.Find(WizardSeedRowStore.LanguageTextFile, nameof(LanguageText.Key), key), nameof(LanguageText.Key));
if (fileKey != null)
return fileKey;
var res = PlatformConsts.AppName;
return (await repoLangKey.FirstOrDefaultAsync(a => a.ResourceName == res && a.Key == key))?.Key ?? key;
}
private void UpsertLanguageRows(WizardSeedRowStore rows, string res, string key, string? textEn, string? textTr)
{
foreach (var (culture, value) in new[] { (cultureNameDefault, textEn), (LanguageCodes.Tr, textTr) })
@ -1194,6 +1214,10 @@ public class ListFormWizardAppService(
}
}
// Endpoint'ler tablodan once gider; tablo secilmese de talebin parcasi olarak silinir.
await _tableCleaner.DeleteCustomEndpointsAsync(
[.. tables.SelectMany(t => t.CustomEndpoints).Distinct(StringComparer.OrdinalIgnoreCase)], result.Items);
if (tables.Count > 0)
await _tableCleaner.DropAsync(tables, dropTables, connections, result.Items);

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
@ -35,6 +36,7 @@ public class WizardTableCleaner(
IRepository<CustomEndpoint, Guid> repoCustomEndpoint,
IRepository<CustomComponent, Guid> repoCustomComponent,
IRepository<CrudEndpoint, Guid> repoCrudEndpoint,
IListFormSeedDataSynchronizer seedDataSynchronizer,
IDynamicDataManager dynamicDataManager,
IConfiguration configuration,
ILogger<WizardTableCleaner> logger) : ITransientDependency
@ -79,6 +81,18 @@ public class WizardTableCleaner(
.Where(u => u.RecordType != WizardRecordTypeEnum.ListForm ||
batchListForms?.Contains(u.Name, StringComparer.OrdinalIgnoreCase) != true)
.ToList();
// Talep silmesinde talebin tablosunu sorgulayan Custom Endpoint'ler talebin parcasidir; tabloyu
// tutmaz, taleple birlikte silinir. Migrate edilmemis endpoint yalnizca seed dosyasindadir.
if (batchListForms != null && HasCreateTableScript(tableName) &&
usedBy.All(u => u.RecordType == WizardRecordTypeEnum.CustomEndpoint))
{
table.CustomEndpoints = [.. usedBy.Select(u => u.Name)
.Concat(await FindSeedEndpointsAsync(table))
.Distinct(StringComparer.OrdinalIgnoreCase)];
usedBy.Clear();
}
if (usedBy.Count > 0)
{
table.Reason = WizardDeleteReasonEnum.InUse;
@ -167,8 +181,53 @@ public class WizardTableCleaner(
return result;
}
/// <summary>
/// Talep silmesinde talebin tablolarini sorgulayan Custom Endpoint'leri veritabanindan ve
/// <c>data/App.DeveloperKit.CustomEndpoints.json</c> dosyasindan siler.
/// </summary>
public async Task DeleteCustomEndpointsAsync(IReadOnlyCollection<string> names, List<WizardDeleteLogItemDto> report)
{
if (names.Count == 0)
return;
var endpoints = await repoCustomEndpoint.GetListAsync(a => names.Contains(a.Name));
if (endpoints.Count > 0)
await repoCustomEndpoint.DeleteManyAsync(endpoints, autoSave: true);
await seedDataSynchronizer.RemoveRowsByFieldAsync(CustomEndpointsListFormCode, nameof(CustomEndpoint.Name), names);
report.AddRange(names.Select(name => new WizardDeleteLogItemDto
{
RecordType = WizardRecordTypeEnum.CustomEndpoint,
Name = name,
Status = WizardDeleteStatusEnum.Deleted,
}));
}
// ── Analiz ─────────────────────────────────────────────────────────────────────
private const string CustomEndpointsListFormCode = PlatformConsts.AppCodes.DeveloperKits.CustomEndpoints;
/// <summary>Henuz migrate edilmemis, yalnizca seed dosyasinda duran ve tabloyu sorgulayan endpoint'ler.</summary>
private async Task<List<string>> FindSeedEndpointsAsync(WizardDeleteTableDto table)
{
var path = ScopeFile(SeedPathResolver.GetDataFilePath(CustomEndpointsListFormCode));
if (!File.Exists(path))
return [];
var file = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(path), ListFormSeedDataFileDto.JsonOptions);
var pattern = TablePattern(table.TableName);
string? Text(Dictionary<string, object> row, string field) =>
row.FirstOrDefault(kv => string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase)).Value?.ToString();
return [.. (file?.Rows ?? [])
.Where(row => string.Equals(Text(row, nameof(CustomEndpoint.DataSourceCode)), table.DataSourceCode, StringComparison.OrdinalIgnoreCase) &&
Text(row, nameof(CustomEndpoint.Sql)) is { } sql && pattern.IsMatch(sql))
.Select(row => Text(row, nameof(CustomEndpoint.Name)))
.OfType<string>()];
}
private async Task ReadTableStateAsync(WizardDeleteTableDto table)
{
try

File diff suppressed because one or more lines are too long

View file

@ -39933,17 +39933,17 @@
"Value": "Ağırlık"
},
{
"Id": "bbcfd955-c535-5c01-b9bb-c7321dd61d89",
"Id": "5961b6ba-f1d4-510a-a9cd-ac723dbad6a8",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.WorkcenterId",
"Value": "Workcenter"
"Key": "App.Listform.ListformField.WorkCenterId",
"Value": "Work Center"
},
{
"Id": "994c5396-1eea-5a2f-a4dc-11d1c3b1bb8b",
"Id": "db209c22-79f7-53aa-b125-06be98097e3e",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.WorkcenterId",
"Key": "App.Listform.ListformField.WorkCenterId",
"Value": "İş Merkezi"
},
{
@ -40702,6 +40702,20 @@
"Key": "App.ListForm.WizardDeleteTableNoPermission",
"Value": "Tablo silmek için SQL Query Manager yetkisi gerekir."
},
{
"Id": "2f3d5aa3-35d7-5d2b-bc3d-ba0363eb838b",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.ListForm.WizardDeleteCustomEndpoints",
"Value": "Custom Endpoints querying this table are deleted with the request:"
},
{
"Id": "5c30228d-7a64-509d-92cb-5218d0cacfc5",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.ListForm.WizardDeleteCustomEndpoints",
"Value": "Bu tabloyu sorgulayan Custom Endpoint'ler taleple birlikte silinir:"
},
{
"Id": "50b48483-4900-53d7-8264-e1a31c899097",
"CultureName": "en",
@ -69290,6 +69304,20 @@
"Key": "App.Mcp.Error.SelectFailed",
"Value": "Sorgu çalışmadı: {0} Bir kolonun var olduğunu varsayma (IsDeleted, TenantId, IsActive her tabloda yoktur); tablonun kolonlarını search_schema ya da INFORMATION_SCHEMA.COLUMNS üzerinde bir SELECT ile kontrol edip sorguyu düzelt."
},
{
"Id": "f78c1319-0925-5e25-af53-eed5f6039eb1",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.Mcp.Error.SelectFailedColumns",
"Value": "The query failed: {0} Actual columns of the tables in the query: {1}. Rewrite the query using only these columns; do not guess column names."
},
{
"Id": "834dbec7-04c1-57c7-9044-a9bc1e348828",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.Mcp.Error.SelectFailedColumns",
"Value": "Sorgu çalışmadı: {0} Sorgudaki tabloların gerçek kolonları: {1}. Sorguyu yalnızca bu kolonlarla yeniden yaz; kolon adı tahmin etme."
},
{
"Id": "aac27480-88f4-5230-9820-f9c7472ef178",
"CultureName": "en",
@ -86929,6 +86957,76 @@
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.Longitude",
"Value": "Boylam"
},
{
"Id": "f62001da-76fc-59e7-a302-39ca902d471e",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.InternalReference",
"Value": "Internal Reference"
},
{
"Id": "c7fa6c98-b2fd-563c-a83f-87639440f864",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.InternalReference",
"Value": "İç Referans"
},
{
"Id": "e5c139da-74ef-51d9-923e-c9d3de7e109b",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.Cost",
"Value": "Cost"
},
{
"Id": "74014c98-a5f8-53de-b16e-d77c86f987b4",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.Cost",
"Value": "Maliyet"
},
{
"Id": "de319c46-4357-5025-86c4-1700e71cd1c5",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.SetupTimeMin",
"Value": "Setup Time (min)"
},
{
"Id": "440df770-7b20-5180-8aff-bd8ef821657d",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.SetupTimeMin",
"Value": "Kurulum Süresi (dk)"
},
{
"Id": "e8104b4f-8b74-56f7-a96d-3126fca31472",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.CleanupTimeMin",
"Value": "Cleanup Time (min)"
},
{
"Id": "cf803733-a414-5abd-a659-70e86431f51b",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.CleanupTimeMin",
"Value": "Temizlik Süresi (dk)"
},
{
"Id": "8c9c9180-00ec-5ca0-9d34-a96c6b6a68df",
"CultureName": "en",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.ExpenseAccount",
"Value": "Expense Account"
},
{
"Id": "415a25cd-c5cf-561b-a827-55259b8108e6",
"CultureName": "tr",
"ResourceName": "Platform",
"Key": "App.Listform.ListformField.ExpenseAccount",
"Value": "Gider Hesabı"
}
]
}

View file

@ -234,6 +234,8 @@ export interface WizardDeleteTableDto {
usedBy: WizardRecordRefDto[]
/** Tabloyla birlikte silinecek seed dosyaları (kapsam klasörüne göre). */
files: string[]
/** Talep silmesinde tabloyu sorgulayan, taleple birlikte silinecek Custom Endpoint'ler. */
customEndpoints?: string[]
}
export interface WizardDeleteAnalysisDto {

View file

@ -596,6 +596,16 @@ const WizardFileManager = () => {
{translate('::App.ListForm.WizardDeleteTableNoPermission')}
</div>
)}
{!!table.customEndpoints?.length && (
<div className="ml-6 mt-1 text-xs text-gray-500">
{translate('::App.ListForm.WizardDeleteCustomEndpoints')}
{table.customEndpoints.map((name) => (
<div key={name} className="break-all font-mono">
{name}
</div>
))}
</div>
)}
</div>
)
}