using System.Text;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// Sprint 22: weekly-job который генерирует
/// docs/db-schema.md с актуальным списком таблиц + колонок +
/// FK-связей + mermaid ER-диаграммой.
///
/// Источник правды — `information_schema` PostgreSQL, не EF-модель
/// (DbContext не отражает реально применённые миграции, а только
/// модель в коде; задача — задокументировать что РЕАЛЬНО в БД).
///
/// Где-то лежит файл `docs/db-schema.md` в content-root API-сборки.
/// На stage/prod после первого прогона он останется в файловой системе
/// контейнера, но для коммита в git кому-то нужно его прочитать и
/// сохранить (job сам не комитит — это требует git-credentials).
///
/// В простейшем варианте этот job просто пишет файл в `/tmp/db-schema-
/// generated.md` + лог; реальная интеграция с git — отдельный workflow.
public class DbSchemaDocsJob
{
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
private readonly ILogger _log;
private readonly IWebHostEnvironment _env;
public DbSchemaDocsJob(AppDbContext db, IConfiguration cfg, ILogger log,
IWebHostEnvironment env)
{
_db = db; _cfg = cfg; _log = log; _env = env;
}
public async Task GenerateAsync(CancellationToken ct = default)
{
var sb = new StringBuilder();
sb.AppendLine("# Схема БД food_market");
sb.AppendLine();
sb.AppendLine($"Сгенерировано автоматически: {DateTime.UtcNow:O}.");
sb.AppendLine();
sb.AppendLine("Источник: `information_schema` PostgreSQL public-схемы.");
sb.AppendLine();
// 1. Таблицы + колонки.
sb.AppendLine("## Таблицы");
sb.AppendLine();
var cols = await _db.Database.SqlQuery($@"
SELECT table_name AS ""TableName"",
column_name AS ""ColumnName"",
data_type AS ""DataType"",
COALESCE(character_maximum_length::text, '') AS ""MaxLength"",
is_nullable AS ""IsNullable"",
COALESCE(column_default, '') AS ""Default""
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name NOT LIKE 'AspNet%' -- скроем Identity (общая schema)
AND table_name NOT LIKE 'OpenIddict%' -- скроем OpenIddict
AND table_name NOT LIKE '__EFMigrations%'
ORDER BY table_name, ordinal_position").ToListAsync(ct);
var byTable = cols.GroupBy(c => c.TableName).OrderBy(g => g.Key).ToList();
foreach (var grp in byTable)
{
sb.AppendLine($"### `{grp.Key}`");
sb.AppendLine();
sb.AppendLine("| Колонка | Тип | Nullable | Default |");
sb.AppendLine("|---|---|---|---|");
foreach (var c in grp)
{
var typeStr = string.IsNullOrEmpty(c.MaxLength) ? c.DataType : $"{c.DataType}({c.MaxLength})";
var def = c.Default.Length > 30 ? c.Default[..30] + "…" : c.Default;
sb.AppendLine($"| {c.ColumnName} | {typeStr} | {c.IsNullable} | {def} |");
}
sb.AppendLine();
}
// 2. FK-связи (для понимания JOIN'ов).
sb.AppendLine("## Связи (Foreign Keys)");
sb.AppendLine();
var fks = await _db.Database.SqlQuery($@"
SELECT
tc.table_name AS ""FromTable"",
kcu.column_name AS ""FromColumn"",
ccu.table_name AS ""ToTable"",
ccu.column_name AS ""ToColumn""
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND tc.table_name NOT LIKE 'AspNet%'
AND tc.table_name NOT LIKE 'OpenIddict%'
ORDER BY tc.table_name, kcu.column_name").ToListAsync(ct);
sb.AppendLine("| Из таблицы | Колонка | → | В таблицу | Колонка |");
sb.AppendLine("|---|---|---|---|---|");
foreach (var fk in fks)
sb.AppendLine($"| {fk.FromTable} | {fk.FromColumn} | → | {fk.ToTable} | {fk.ToColumn} |");
sb.AppendLine();
// 3. Mermaid ER-диаграмма (ограничиваем top-30 таблиц по кол-ву связей
// чтобы не получить нечитаемый клубок).
sb.AppendLine("## ER-диаграмма (mermaid)");
sb.AppendLine();
sb.AppendLine("Ограничено топ-20 таблицами по числу FK-связей.");
sb.AppendLine();
sb.AppendLine("```mermaid");
sb.AppendLine("erDiagram");
var fkByTable = fks.GroupBy(f => f.FromTable)
.OrderByDescending(g => g.Count())
.Take(20)
.Select(g => g.Key)
.ToHashSet();
foreach (var fk in fks.Where(f => fkByTable.Contains(f.FromTable) || fkByTable.Contains(f.ToTable)))
{
sb.AppendLine($" {fk.FromTable} }}o--|| {fk.ToTable} : {fk.FromColumn}");
}
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine("## Что НЕ показано");
sb.AppendLine();
sb.AppendLine("- Таблицы `AspNet*` (ASP.NET Identity) и `OpenIddict*` — стандартные, см. их документацию.");
sb.AppendLine("- Hangfire-таблицы в схеме `hangfire` — внутренние, не часть domain-модели.");
sb.AppendLine("- Индексы — см. отдельный `\\di+` в psql.");
var content = sb.ToString();
// Сохраняем в content-root API. На stage/prod после рестарта файл
// пропадёт (volume не привязан), но это норма для job'a — он
// подхватывается weekly и переписывает. Реальная фиксация в git
// — через отдельный workflow (см. .forgejo/workflows/db-schema-docs.yml).
var path = Path.Combine(_env.ContentRootPath, "db-schema-generated.md");
try
{
await File.WriteAllTextAsync(path, content, ct);
_log.LogInformation("DbSchemaDocs: записан {Path} ({Bytes} bytes)", path, content.Length);
}
catch (Exception ex)
{
_log.LogWarning(ex, "DbSchemaDocs: не удалось записать {Path}", path);
}
return content;
}
private record ColumnInfo(string TableName, string ColumnName, string DataType,
string MaxLength, string IsNullable, string Default);
private record FkInfo(string FromTable, string FromColumn, string ToTable, string ToColumn);
}