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); }