Добавлены mermaid диаграммы

This commit is contained in:
FrigaT
2025-12-25 12:59:20 +03:00
parent 9abf8daf90
commit 0dae811dd0
15 changed files with 1063 additions and 228 deletions

View File

@@ -0,0 +1,402 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
namespace SQLLinter.Infrastructure.Diagram;
public enum BpmnNodeType
{
Start,
End,
Task,
Gateway,
Subprocess,
}
public class BpmnNode
{
public string Id { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public BpmnNodeType Type { get; set; }
}
public class BpmnEdge
{
public string From { get; set; } = string.Empty;
public string To { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public bool Dashed { get; set; }
}
public class BpmnProcess
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public List<BpmnNode> Nodes { get; set; } = new();
public List<BpmnEdge> Edges { get; set; } = new();
}
public class BpmnDiagram
{
public BpmnProcess Main { get; set; } = new BpmnProcess { Id = "Main", Name = "Main" };
public List<BpmnProcess> Subprocesses { get; set; } = new();
}
public static class BpmnBuilder
{
public static BpmnDiagram Build(TSqlFragment fragment)
{
var visitor = new BpmnVisitor();
fragment.Accept(visitor);
return visitor.Diagram;
}
private class BpmnVisitor : TSqlFragmentVisitor
{
public BpmnDiagram Diagram { get; } = new BpmnDiagram();
private Dictionary<string, string> _lastNodeByProcess = new();
private BpmnProcess _currentProcess;
private int _nodeCounter = 0;
public BpmnVisitor()
{
_currentProcess = Diagram.Main;
// create a Start node for main
var start = new BpmnNode { Id = NewId(), Label = "Start", Type = BpmnNodeType.Start };
Diagram.Main.Nodes.Add(start);
_lastNodeByProcess[Diagram.Main.Id] = start.Id;
}
private string NewId() => "n" + (_nodeCounter++);
private void AddNode(BpmnProcess proc, BpmnNode node)
{
if (string.IsNullOrEmpty(node.Id)) node.Id = NewId();
proc.Nodes.Add(node);
if (_lastNodeByProcess.TryGetValue(proc.Id, out var last) && !string.IsNullOrEmpty(last))
{
proc.Edges.Add(new BpmnEdge { From = last, To = node.Id });
}
_lastNodeByProcess[proc.Id] = node.Id;
}
private void AddNodeToCurrent(string label, BpmnNodeType type)
{
var node = new BpmnNode { Id = NewId(), Label = label, Type = type };
AddNode(_currentProcess, node);
}
public override void Visit(TSqlScript node)
{
_currentProcess = Diagram.Main;
if (!_lastNodeByProcess.ContainsKey(_currentProcess.Id)) _lastNodeByProcess[_currentProcess.Id] = null;
base.Visit(node);
// add End node for main process
var end = new BpmnNode { Id = NewId(), Label = "End", Type = BpmnNodeType.End };
AddNode(Diagram.Main, end);
}
public override void Visit(CreateProcedureStatement node)
{
// AST-based extraction of procedure name
var name = FindSchemaObjectName(node) ?? ("proc" + Diagram.Subprocesses.Count);
var pid = SanitizeId(name);
var proc = new BpmnProcess { Id = pid, Name = name };
// synthetic pool/label node
var poolNode = new BpmnNode { Id = pid + "_pool", Label = name, Type = BpmnNodeType.Subprocess };
proc.Nodes.Add(poolNode);
// create start node for subprocess
var start = new BpmnNode { Id = pid + "_start", Label = "Start", Type = BpmnNodeType.Start };
proc.Nodes.Add(start);
// set last node pointer to start
_lastNodeByProcess[proc.Id] = start.Id;
Diagram.Subprocesses.Add(proc);
var old = _currentProcess;
_currentProcess = proc;
if (!_lastNodeByProcess.ContainsKey(proc.Id)) _lastNodeByProcess[proc.Id] = start.Id;
base.Visit(node);
// after visiting procedure body add End node
var end = new BpmnNode { Id = pid + "_end", Label = "End", Type = BpmnNodeType.End };
AddNode(proc, end);
_currentProcess = old;
}
public override void Visit(ExecuteStatement node)
{
// AST-based extraction for called proc
var called = FindProcedureNameFromExecute(node);
var label = called != null ? $"EXEC {called}" : node.ToString();
// task in current process
var task = new BpmnNode { Id = NewId(), Label = label, Type = BpmnNodeType.Task };
AddNode(_currentProcess, task);
// if calling known subprocess, add dashed edge from this node to subprocess start
if (called != null)
{
var proc = Diagram.Subprocesses.FirstOrDefault(p => string.Equals(p.Name, called, StringComparison.OrdinalIgnoreCase) || string.Equals(p.Id, SanitizeId(called), StringComparison.OrdinalIgnoreCase));
if (proc != null)
{
// add dashed edge in main process (from this task to subprocess pool node)
Diagram.Main.Edges.Add(new BpmnEdge { From = task.Id, To = proc.Nodes.First().Id, Dashed = true });
}
}
base.Visit(node);
}
public override void Visit(IfStatement node)
{
var gid = new BpmnNode { Id = NewId(), Label = "IF", Type = BpmnNodeType.Gateway };
AddNode(_currentProcess, gid);
// then / else placeholders
if (node.ThenStatement != null)
{
var thenNode = new BpmnNode { Id = NewId(), Label = "THEN", Type = BpmnNodeType.Task };
AddNode(_currentProcess, thenNode);
_currentProcess.Edges.Add(new BpmnEdge { From = gid.Id, To = thenNode.Id });
}
if (node.ElseStatement != null)
{
var elseNode = new BpmnNode { Id = NewId(), Label = "ELSE", Type = BpmnNodeType.Task };
AddNode(_currentProcess, elseNode);
_currentProcess.Edges.Add(new BpmnEdge { From = gid.Id, To = elseNode.Id });
}
base.Visit(node);
}
public override void Visit(SelectStatement node)
{
var label = "SELECT";
if (node.QueryExpression is QuerySpecification qs && qs.SelectElements != null)
{
label = $"SELECT ({qs.SelectElements.Count})";
}
AddNodeToCurrent(label, BpmnNodeType.Task);
base.Visit(node);
}
public override void Visit(InsertStatement node)
{
var target = FindTargetNameForDml(node);
var label = "INSERT" + (string.IsNullOrEmpty(target) ? string.Empty : " -> " + target);
AddNodeToCurrent(label, BpmnNodeType.Task);
base.Visit(node);
}
public override void Visit(UpdateStatement node)
{
var target = FindTargetNameForDml(node);
var label = "UPDATE" + (string.IsNullOrEmpty(target) ? string.Empty : " -> " + target);
AddNodeToCurrent(label, BpmnNodeType.Task);
base.Visit(node);
}
public override void Visit(DeleteStatement node)
{
var target = FindTargetNameForDml(node);
var label = "DELETE" + (string.IsNullOrEmpty(target) ? string.Empty : " -> " + target);
AddNodeToCurrent(label, BpmnNodeType.Task);
base.Visit(node);
}
// AST helpers
private static string? FindSchemaObjectName(TSqlFragment fragment)
{
var finder = new SchemaNameFinder();
fragment.Accept(finder);
return finder.FoundName;
}
private static string? FindProcedureNameFromExecute(ExecuteStatement node)
{
// Prefer ProcedureReference inside the statement
var finder = new ProcedureReferenceFinder();
node.Accept(finder);
return finder.FoundName;
}
private static string? FindTargetNameForDml(TSqlFragment fragment)
{
var finder = new TargetTableFinder();
fragment.Accept(finder);
return finder.FoundName;
}
private class SchemaNameFinder : TSqlFragmentVisitor
{
public string? FoundName { get; private set; }
public override void Visit(SchemaObjectName node)
{
if (FoundName == null)
{
try
{
if (node.Identifiers != null && node.Identifiers.Count > 0)
{
FoundName = string.Join('.', node.Identifiers.Select(id => id.Value));
}
else
{
FoundName = node.ToString();
}
}
catch
{
FoundName = node.ToString();
}
}
base.Visit(node);
}
public override void Visit(ProcedureReferenceName node)
{
if (FoundName == null)
{
try
{
if (node.ProcedureReference != null && node.ProcedureReference.Name != null)
{
// procedure reference name may contain identifiers
FoundName = node.ProcedureReference.Name.ToString();
}
else
{
FoundName = node.ToString();
}
}
catch
{
FoundName = node.ToString();
}
}
base.Visit(node);
}
}
private class ProcedureReferenceFinder : TSqlFragmentVisitor
{
public string? FoundName { get; private set; }
public override void Visit(ProcedureReference node)
{
if (FoundName == null)
{
try
{
if (node.Name != null)
{
if (node.Name.Identifiers != null && node.Name.Identifiers.Count > 0)
FoundName = string.Join('.', node.Name.Identifiers.Select(id => id.Value));
else
FoundName = node.Name.ToString();
}
}
catch
{
FoundName = node.ToString();
}
}
base.Visit(node);
}
public override void Visit(ExecuteSpecification node)
{
base.Visit(node);
}
}
private class TargetTableFinder : TSqlFragmentVisitor
{
public string? FoundName { get; private set; }
private int _bestDepth = int.MaxValue;
private int _depth = 0;
public override void Visit(TSqlFragment node)
{
_depth++;
base.Visit(node);
_depth--;
}
public override void Visit(NamedTableReference node)
{
if (node == null) return;
int depth = _depth;
if (depth < _bestDepth)
{
_bestDepth = depth;
try
{
if (node.SchemaObject != null && node.SchemaObject.Identifiers != null && node.SchemaObject.Identifiers.Count > 0)
FoundName = string.Join('.', node.SchemaObject.Identifiers.Select(id => id.Value));
else
FoundName = node.SchemaObject?.ToString() ?? node.ToString();
if (node.Alias != null && !string.IsNullOrWhiteSpace(node.Alias.Value))
FoundName += " AS " + node.Alias.Value;
}
catch
{
FoundName = node.SchemaObject?.ToString() ?? node.ToString();
}
}
base.Visit(node);
}
public override void Visit(VariableTableReference node)
{
if (node == null) return;
int depth = _depth;
if (depth < _bestDepth)
{
_bestDepth = depth;
FoundName = node.Variable?.ToString();
}
base.Visit(node);
}
public override void Visit(QueryDerivedTable node)
{
if (node == null) return;
int depth = _depth;
if (depth < _bestDepth && node.Alias != null && !string.IsNullOrWhiteSpace(node.Alias.Value))
{
_bestDepth = depth;
FoundName = node.Alias.Value + " (derived)";
}
// still traverse inside
base.Visit(node);
}
public override void Visit(CommonTableExpression node)
{
// CTE definitions: prefer not to pick CTE as target unless no other table found
base.Visit(node);
}
}
private static string SanitizeId(string s)
{
if (string.IsNullOrEmpty(s)) return "id";
var sb = new System.Text.StringBuilder();
foreach (var ch in s)
{
if (char.IsLetterOrDigit(ch)) sb.Append(ch);
else sb.Append('_');
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,51 @@
using System.Text;
using Microsoft.SqlServer.TransactSql.ScriptDom;
using System;
using System.Linq;
using System.Collections.Generic;
namespace SQLLinter.Infrastructure.Diagram;
public static class FragmentDiagramBuilder
{
public static string RenderMermaid(TSqlFragment fragment)
{
if (fragment == null) return string.Empty;
var diagram = BpmnBuilder.Build(fragment);
return MermaidRenderer.RenderMarkdown(diagram);
}
public static string RenderHtmlSvg(TSqlFragment fragment)
{
if (fragment == null) return string.Empty;
var diagram = BpmnBuilder.Build(fragment);
// Use mermaid HTML instead of SVG fallback
return MermaidRenderer.RenderHtml(diagram);
}
// keep helpers used by earlier code if needed
private static string Escape(string s)
{
if (s == null) return string.Empty;
return System.Net.WebUtility.HtmlEncode(s).Replace("\n", " ").Replace("\r", " ");
}
private static string Truncate(string s, int len)
{
if (s == null) return string.Empty;
if (s.Length <= len) return s;
return s.Substring(0, len - 3) + "...";
}
private static string SanitizeId(string s)
{
if (string.IsNullOrEmpty(s)) return "id";
var sb = new StringBuilder();
foreach (var ch in s)
{
if (char.IsLetterOrDigit(ch)) sb.Append(ch);
else sb.Append('_');
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,147 @@
using System.Text;
namespace SQLLinter.Infrastructure.Diagram;
public static class MermaidRenderer
{
public static string ToMermaidContent(BpmnDiagram diagram)
{
var sb = new StringBuilder();
sb.AppendLine("flowchart TB");
// main: print Start, then tasks/gateways, then End
var mainStart = diagram.Main.Nodes.Where(n => n.Type == BpmnNodeType.Start).ToList();
var mainTasks = diagram.Main.Nodes.Where(n => n.Type == BpmnNodeType.Task || n.Type == BpmnNodeType.Gateway).ToList();
var mainEnd = diagram.Main.Nodes.Where(n => n.Type == BpmnNodeType.End).ToList();
foreach (var node in mainStart)
{
var nid = SanitizeId(node.Id);
var label = Escape(node.Label);
sb.AppendLine($" {nid}((\"{label}\"))");
}
foreach (var node in mainTasks)
{
var nid = SanitizeId(node.Id);
var label = Escape(node.Label);
if (node.Type == BpmnNodeType.Gateway)
sb.AppendLine($" {nid}{{\"{label}\"}}");
else
sb.AppendLine($" {nid}[\"{label}\"]");
}
foreach (var node in mainEnd)
{
var nid = SanitizeId(node.Id);
var label = Escape(node.Label);
sb.AppendLine($" {nid}((\"{label}\"))");
}
sb.AppendLine();
// subprocesses as subgraphs: pool label, start, tasks/gateways, end
foreach (var proc in diagram.Subprocesses)
{
var procId = SanitizeId(proc.Id);
sb.AppendLine($" subgraph {procId} [\"{Escape(proc.Name)}\"]");
var startNodes = proc.Nodes.Where(n => n.Type == BpmnNodeType.Start).ToList();
var taskNodes = proc.Nodes.Where(n => n.Type == BpmnNodeType.Task || n.Type == BpmnNodeType.Gateway).ToList();
var endNodes = proc.Nodes.Where(n => n.Type == BpmnNodeType.End).ToList();
var poolNodes = proc.Nodes.Where(n => n.Type == BpmnNodeType.Subprocess).ToList();
// pool label nodes (usually first)
foreach (var node in poolNodes)
{
var nid = SanitizeId(node.Id);
var label = Escape(node.Label);
sb.AppendLine($" {nid}[\"{label}\"]");
}
foreach (var node in startNodes)
{
var nid = SanitizeId(node.Id);
var label = Escape(node.Label);
sb.AppendLine($" {nid}((\"{label}\"))");
}
foreach (var node in taskNodes)
{
var nid = SanitizeId(node.Id);
var label = Escape(node.Label);
if (node.Type == BpmnNodeType.Gateway)
sb.AppendLine($" {nid}{{\"{label}\"}}");
else
sb.AppendLine($" {nid}[\"{label}\"]");
}
foreach (var node in endNodes)
{
var nid = SanitizeId(node.Id);
var label = Escape(node.Label);
sb.AppendLine($" {nid}((\"{label}\"))");
}
// edges inside subprocess
foreach (var e in proc.Edges)
{
var from = SanitizeId(e.From);
var to = SanitizeId(e.To);
var arrow = e.Dashed ? "-.->" : "-->";
var lbl = string.IsNullOrWhiteSpace(e.Label) ? string.Empty : $" |{Escape(e.Label)}|";
sb.AppendLine($" {from} {arrow} {to}{lbl}");
}
sb.AppendLine(" end");
sb.AppendLine();
}
// main edges
foreach (var e in diagram.Main.Edges)
{
var from = SanitizeId(e.From);
var to = SanitizeId(e.To);
var arrow = e.Dashed ? "-.->" : "-->";
var lbl = string.IsNullOrWhiteSpace(e.Label) ? string.Empty : $" |{Escape(e.Label)}|";
sb.AppendLine($" {from} {arrow} {to}{lbl}");
}
return sb.ToString();
}
public static string RenderMarkdown(BpmnDiagram diagram)
{
var content = ToMermaidContent(diagram);
var sb = new StringBuilder();
sb.AppendLine("```mermaid");
sb.Append(content);
sb.AppendLine("```");
return sb.ToString();
}
public static string RenderHtml(BpmnDiagram diagram)
{
var content = ToMermaidContent(diagram);
var sb = new StringBuilder();
// Put raw mermaid text inside .mermaid container so mermaid.js can render it
sb.AppendLine("<div class=\"mermaid\">\n" + content + "\n</div>");
return sb.ToString();
}
private static string Escape(string s)
{
if (s == null) return string.Empty;
return s.Replace("\"", "\\\"").Replace("\n", " ").Replace("\r", " ");
}
private static string SanitizeId(string s)
{
if (string.IsNullOrEmpty(s)) return "id";
var sb = new StringBuilder();
foreach (var ch in s)
{
if (char.IsLetterOrDigit(ch)) sb.Append(ch);
else sb.Append('_');
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,73 @@
using SQLLinter.Core.Interfaces;
using SQLLinter.Infrastructure.Interfaces;
using SQLLinter.Infrastructure.Parser;
namespace SQLLinter.Infrastructure.Diagram;
public class SqlDiagramProcessor : ISqlDiagramProcessor
{
private readonly BpmnDiagram _bpmnDiagram;
private readonly IFragmentBuilder _fragmentBuilder;
private readonly ISqlStreamReaderBuilder _sqlStreamReaderBuilder;
public SqlDiagramProcessor(IFragmentBuilder fragmentBuilder, BpmnDiagram bpmnDiagram)
: this(fragmentBuilder, bpmnDiagram, new SqlStreamReaderBuilder()) { }
public SqlDiagramProcessor(IFragmentBuilder fragmentBuilder, BpmnDiagram bpmnDiagram, ISqlStreamReaderBuilder sqlStreamReaderBuilder)
{
_fragmentBuilder = fragmentBuilder;
_bpmnDiagram = bpmnDiagram;
_sqlStreamReaderBuilder = sqlStreamReaderBuilder;
}
public void ProcessList(List<string> filePaths)
{
foreach (var path in filePaths)
{
ProcessFile(path);
}
}
public void ProcessPath(string path)
{
ProcessFile(path);
}
private void ProcessFile(string filePath)
{
var fileStream = GetFileContents(filePath);
HandleProcessing(filePath, fileStream);
}
public void ProcessList(Dictionary<string, Stream> files)
{
foreach (var file in files)
{
HandleProcessing(file.Key, file.Value);
}
}
private void HandleProcessing(string filePath, Stream fileStream)
{
var fragment = _fragmentBuilder.GetFragment(filePath, GetSqlTextReader(fileStream), out var errors);
if (fragment == null || errors.Count > 0)
{
return;
}
var diagramm = BpmnBuilder.Build(fragment);
_bpmnDiagram.Subprocesses.Add(diagramm.Main);
}
private Stream GetFileContents(string filePath)
{
return File.OpenRead(filePath);
}
private StreamReader GetSqlTextReader(Stream sqlFileStream)
{
return _sqlStreamReaderBuilder.CreateReader(sqlFileStream);
}
}

View File

@@ -8,20 +8,14 @@ public class SqlFileProcessor : ISqlFileProcessor
{
private readonly IRuleVisitor ruleVisitor;
private readonly IReporter reporter;
private readonly IPluginHandler pluginHandler;
private readonly IRuleExceptionFinder ruleExceptionFinder;
public SqlFileProcessor(
IRuleVisitor ruleVisitor,
IPluginHandler pluginHandler,
IReporter reporter)
IPluginHandler pluginHandler
)
{
this.ruleVisitor = ruleVisitor;
this.pluginHandler = pluginHandler;
this.reporter = reporter;
ruleExceptionFinder = new RuleExceptionFinder(pluginHandler.RuleWithNames);
}

View File

@@ -1,14 +0,0 @@
namespace SQLLinter.Infrastructure.Reporters;
public class FileReporter : Reporter
{
public virtual void SaveReport(string path)
{
File.WriteAllText(path, GetContent());
}
public virtual string GetContent()
{
return string.Join(Environment.NewLine, this.Violations);
}
}

View File

@@ -1,145 +0,0 @@
using SQLLinter.Common;
using System.Text;
namespace SQLLinter.Infrastructure.Reporters;
public class HTMLReporter : FileReporter
{
public string GetContent()
{
var violations = Violations;
if (violations.Count == 0)
{
return "<p><em>Нет нарушений</em></p>";
}
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key);
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ru\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<title>Отчёт по SQLпроверкам</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: 'Segoe UI', Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; color: #000; }");
sb.AppendLine("h1 { padding: 20px; margin: 0; background-color: #0078d4; color: white; }");
sb.AppendLine("h2 { margin-top: 20px; color: #333; }");
sb.AppendLine("h3 { margin-top: 15px; color: #555; }");
sb.AppendLine("table { border-collapse: collapse; width: 100%; margin: 20px 0; box-shadow: 0 2px 6px rgba(0,0,0,0.1); background-color: white; border-radius: 4px; overflow: hidden; }");
sb.AppendLine("th, td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; line-height: 1.2; }");
sb.AppendLine("th { background-color: #fafafa; font-weight: 600; }");
sb.AppendLine("td.line, td.column { width: 60px; text-align: center; }");
sb.AppendLine("td.rule { width: 300px; text-align: left; }");
sb.AppendLine("td.index { width: 40px; text-align: center; }");
sb.AppendLine(".critical { border-left: 4px solid #d13438; padding: 10px; margin-bottom: 20px; background-color: #fde7e9; }");
sb.AppendLine(".warning { border-left: 4px solid #ffaa44; padding: 10px; margin-bottom: 20px; background-color: #fff4ce; }");
sb.AppendLine(".info { border-left: 4px solid #0078d4; padding: 10px; margin-bottom: 20px; background-color: #deecf9; }");
sb.AppendLine(".tabs { position: fixed; bottom: 0; left: 0; right: 0; background-color: #ffffff; border-top: 1px solid #ccc; padding: 10px; display: flex; overflow-x: auto; scrollbar-width: thin; justify-content: flex-start; box-shadow: 0 -2px 6px rgba(0,0,0,0.1); }");
sb.AppendLine(".tab { margin-right: 10px; padding: 8px 16px; border-radius: 4px; background-color: #f3f2f1; cursor: pointer; transition: background-color 0.2s; }");
sb.AppendLine(".tab:hover { background-color: #e1dfdd; }");
sb.AppendLine(".tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(".file-report { display: none; padding: 20px 0; }");
sb.AppendLine(".file-report.active { display: block; }");
// Тёмная тема
sb.AppendLine("@media (prefers-color-scheme: dark) {");
sb.AppendLine(" body { background-color: #1e1e1e; color: #ddd; }");
sb.AppendLine(" h1 { background-color: #005a9e; }");
sb.AppendLine(" h2, h3 { color: #ddd; }");
sb.AppendLine(" table { background-color: #2d2d2d; box-shadow: none; }");
sb.AppendLine(" th { background-color: #3c3c3c; color: #fff; }");
sb.AppendLine(" td { border: 1px solid #444; }");
sb.AppendLine(" .tabs { background-color: #2d2d2d; border-top: 1px solid #444; }");
sb.AppendLine(" .tab { background-color: #3c3c3c; color: #ddd; }");
sb.AppendLine(" .tab:hover { background-color: #555; }");
sb.AppendLine(" .tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(" .critical { background-color: #4d1f1f; border-left-color: #d13438; }");
sb.AppendLine(" .warning { background-color: #4d3b1f; border-left-color: #ffaa44; }");
sb.AppendLine(" .info { background-color: #1f3b4d; border-left-color: #0078d4; }");
sb.AppendLine("}");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
//sb.AppendLine("<h1>Отчёт по SQLпроверкам</h1>");
int fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
string divId = $"file_{fileIndex}";
sb.AppendLine($"<div id=\"{divId}\" class=\"file-report{(fileIndex == 0 ? " active" : "")}\">");
sb.AppendLine($"<h1>Файл: {fileGroup.Key}</h1>");
var groupedBySeverity = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key);
foreach (var severityGroup in groupedBySeverity)
{
string severityClass = severityGroup.Key switch
{
RuleViolationSeverity.Critical => "critical",
RuleViolationSeverity.Warning => "warning",
RuleViolationSeverity.Info => "info",
_ => ""
};
sb.AppendLine($"<div class=\"{severityClass}\">");
sb.AppendLine($"<h3>{severityGroup.Key}</h3>");
sb.AppendLine("<table>");
sb.AppendLine("<thead>");
sb.AppendLine("<tr><th>#</th><th>Строка</th><th>Колонка</th><th>Правило</th><th>Описание</th></tr>");
sb.AppendLine("</thead>");
sb.AppendLine("<tbody>");
int rowIndex = 1;
foreach (var v in severityGroup
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
{
sb.AppendLine($"<tr><td class=\"index\">{rowIndex}</td><td class=\"line\">{v.Line}</td><td class=\"column\">{v.Column}</td><td class=\"rule\">{v.RuleName}</td><td>{v.Text}</td></tr>");
rowIndex++;
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
fileIndex++;
}
// Табы снизу
sb.AppendLine("<div class=\"tabs\">");
fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
sb.AppendLine($"<div class=\"tab{(fileIndex == 0 ? " active" : "")}\" onclick=\"showReport('file_{fileIndex}', this)\">{fileGroup.Key}</div>");
fileIndex++;
}
sb.AppendLine("</div>");
// JS для переключения
sb.AppendLine("<script>");
sb.AppendLine("function showReport(id, tab) {");
sb.AppendLine(" document.querySelectorAll('.file-report').forEach(el => el.classList.remove('active'));");
sb.AppendLine(" document.getElementById(id).classList.add('active');");
sb.AppendLine(" document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));");
sb.AppendLine(" tab.classList.add('active');");
sb.AppendLine(" window.scrollTo({ top: 0, behavior: 'smooth' });");
sb.AppendLine("}");
sb.AppendLine("document.querySelector('.tabs').addEventListener('wheel', function(e) {");
sb.AppendLine(" e.preventDefault();");
sb.AppendLine(" this.scrollLeft += e.deltaY;");
sb.AppendLine("});");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
}

View File

@@ -0,0 +1,242 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
using System.Text;
namespace SQLLinter.Infrastructure.Reporters;
public class HtmlReportFormatter : IReportFormatter
{
public string Format(List<IRuleViolation> violations)
{
if (violations.Count == 0)
{
return "<p><em>Нет нарушений</em></p>";
}
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key);
var sb = new StringBuilder();
GenerateBeginningHtml(sb);
int fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
string divId = $"file_{fileIndex}";
sb.AppendLine($"<div id=\"{divId}\" class=\"file-report{(fileIndex == 0 ? " active" : "")}\">");
sb.AppendLine($"<h1>Файл: {fileGroup.Key}</h1>");
var groupedBySeverity = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key);
foreach (var severityGroup in groupedBySeverity)
{
string severityClass = severityGroup.Key switch
{
RuleViolationSeverity.Critical => "critical",
RuleViolationSeverity.Warning => "warning",
RuleViolationSeverity.Info => "info",
_ => ""
};
sb.AppendLine($"<div class=\"{severityClass}\">");
sb.AppendLine($"<h3>{severityGroup.Key}</h3>");
sb.AppendLine("<table>");
sb.AppendLine("<thead>");
sb.AppendLine("<tr><th>#</th><th>Строка</th><th>Колонка</th><th>Правило</th><th>Описание</th></tr>");
sb.AppendLine("</thead>");
sb.AppendLine("<tbody>");
int rowIndex = 1;
foreach (var v in severityGroup
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
{
sb.AppendLine($"<tr><td class=\"index\">{rowIndex}</td><td class=\"line\">{v.Line}</td><td class=\"column\">{v.Column}</td><td class=\"rule\">{v.RuleName}</td><td>{v.Text}</td></tr>");
rowIndex++;
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
fileIndex++;
}
// Табы снизу
sb.AppendLine("<div class=\"tabs\">");
fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
sb.AppendLine($"<div class=\"tab{(fileIndex == 0 ? " active" : "")}\" onclick=\"showReport('file_{fileIndex}', this)\">{fileGroup.Key}</div>");
fileIndex++;
}
sb.AppendLine("</div>");
GenerateEndingHtml(sb);
return sb.ToString();
}
public string Format(List<IRuleViolation> violations, BpmnDiagram diagram)
{
if (violations.Count == 0)
{
return "<p><em>Нет нарушений</em></p>";
}
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key);
var sb = new StringBuilder();
GenerateBeginningHtml(sb);
int fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
string divId = $"file_{fileIndex}";
sb.AppendLine($"<div id=\"{divId}\" class=\"file-report{(fileIndex == 0 ? " active" : "")}\">");
sb.AppendLine($"<h1>Файл: {fileGroup.Key}</h1>");
var groupedBySeverity = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key);
foreach (var severityGroup in groupedBySeverity)
{
string severityClass = severityGroup.Key switch
{
RuleViolationSeverity.Critical => "critical",
RuleViolationSeverity.Warning => "warning",
RuleViolationSeverity.Info => "info",
_ => ""
};
sb.AppendLine($"<div class=\"{severityClass}\">");
sb.AppendLine($"<h3>{severityGroup.Key}</h3>");
sb.AppendLine("<table>");
sb.AppendLine("<thead>");
sb.AppendLine("<tr><th>#</th><th>Строка</th><th>Колонка</th><th>Правило</th><th>Описание</th></tr>");
sb.AppendLine("</thead>");
sb.AppendLine("<tbody>");
int rowIndex = 1;
foreach (var v in severityGroup
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
{
sb.AppendLine($"<tr><td class=\"index\">{rowIndex}</td><td class=\"line\">{v.Line}</td><td class=\"column\">{v.Column}</td><td class=\"rule\">{v.RuleName}</td><td>{v.Text}</td></tr>");
rowIndex++;
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
fileIndex++;
}
//mermaid диаграмма
sb.AppendLine($"<div id=\"mermaid\" class=\"file-report{(fileIndex == 0 ? " active" : "")}\">");
sb.AppendLine($"<h1>Диаграмма</h1>");
var mermaid = MermaidRenderer.RenderHtml(diagram);
sb.AppendLine(mermaid);
sb.AppendLine("</div>");
// Табы снизу
sb.AppendLine("<div class=\"tabs\">");
fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
sb.AppendLine($"<div class=\"tab{(fileIndex == 0 ? " active" : "")}\" onclick=\"showReport('file_{fileIndex}', this)\">{fileGroup.Key}</div>");
fileIndex++;
}
sb.AppendLine($"<div class=\"tab{(fileIndex == 0 ? " active" : "")}\" onclick=\"showReport('mermaid', this)\">Диграмма</div>");
sb.AppendLine("</div>");
GenerateEndingHtml(sb);
return sb.ToString();
}
private void GenerateBeginningHtml(StringBuilder sb)
{
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ru\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<title>Отчёт по SQLпроверкам</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: 'Segoe UI', Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; color: #000; }");
sb.AppendLine("h1 { padding: 20px; margin: 0; background-color: #0078d4; color: white; }");
sb.AppendLine("h2 { margin-top: 20px; color: #333; }");
sb.AppendLine("h3 { margin-top: 15px; color: #555; }");
sb.AppendLine("table { border-collapse: collapse; width: 100%; margin: 20px 0; box-shadow: 0 2px 6px rgba(0,0,0,0.1); background-color: white; border-radius: 4px; overflow: hidden; }");
sb.AppendLine("th, td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; line-height: 1.2; }");
sb.AppendLine("th { background-color: #fafafa; font-weight: 600; }");
sb.AppendLine("td.line, td.column { width: 60px; text-align: center; }");
sb.AppendLine("td.rule { width: 300px; text-align: left; }");
sb.AppendLine("td.index { width: 40px; text-align: center; }");
sb.AppendLine(".critical { border-left: 4px solid #d13438; padding: 10px; margin-bottom: 20px; background-color: #fde7e9; }");
sb.AppendLine(".warning { border-left: 4px solid #ffaa44; padding: 10px; margin-bottom: 20px; background-color: #fff4ce; }");
sb.AppendLine(".info { border-left: 4px solid #0078d4; padding: 10px; margin-bottom: 20px; background-color: #deecf9; }");
sb.AppendLine(".tabs { position: fixed; bottom: 0; left: 0; right: 0; background-color: #ffffff; border-top: 1px solid #ccc; padding: 10px; display: flex; overflow-x: auto; scrollbar-width: thin; justify-content: flex-start; box-shadow: 0 -2px 6px rgba(0,0,0,0.1); }");
sb.AppendLine(".tab { margin-right: 10px; padding: 8px 16px; border-radius: 4px; background-color: #f3f2f1; cursor: pointer; transition: background-color 0.2s; }");
sb.AppendLine(".tab:hover { background-color: #e1dfdd; }");
sb.AppendLine(".tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(".file-report { display: none; padding: 20px 0; }");
sb.AppendLine(".file-report.active { display: block; }");
// Тёмная тема
sb.AppendLine("@media (prefers-color-scheme: dark) {");
sb.AppendLine(" body { background-color: #1e1e1e; color: #ddd; }");
sb.AppendLine(" h1 { background-color: #005a9e; }");
sb.AppendLine(" h2, h3 { color: #ddd; }");
sb.AppendLine(" table { background-color: #2d2d2d; box-shadow: none; }");
sb.AppendLine(" th { background-color: #3c3c3c; color: #fff; }");
sb.AppendLine(" td { border: 1px solid #444; }");
sb.AppendLine(" .tabs { background-color: #2d2d2d; border-top: 1px solid #444; }");
sb.AppendLine(" .tab { background-color: #3c3c3c; color: #ddd; }");
sb.AppendLine(" .tab:hover { background-color: #555; }");
sb.AppendLine(" .tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(" .critical { background-color: #4d1f1f; border-left-color: #d13438; }");
sb.AppendLine(" .warning { background-color: #4d3b1f; border-left-color: #ffaa44; }");
sb.AppendLine(" .info { background-color: #1f3b4d; border-left-color: #0078d4; }");
sb.AppendLine("}");
sb.AppendLine("</style>");
sb.AppendLine("<script src=\"https://unpkg.com/mermaid@10/dist/mermaid.min.js\"></script>");
sb.AppendLine("<script>mermaid.initialize({ startOnLoad: true, securityLevel: 'loose' });</script>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
//sb.AppendLine("<h1>Отчёт по SQLпроверкам</h1>");
}
private void GenerateEndingHtml(StringBuilder sb)
{
// JS для переключения
sb.AppendLine("<script>");
sb.AppendLine("function showReport(id, tab) {");
sb.AppendLine(" document.querySelectorAll('.file-report').forEach(el => el.classList.remove('active'));");
sb.AppendLine(" document.getElementById(id).classList.add('active');");
sb.AppendLine(" document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));");
sb.AppendLine(" tab.classList.add('active');");
sb.AppendLine(" window.scrollTo({ top: 0, behavior: 'smooth' });");
sb.AppendLine("}");
sb.AppendLine("document.querySelector('.tabs').addEventListener('wheel', function(e) {");
sb.AppendLine(" e.preventDefault();");
sb.AppendLine(" this.scrollLeft += e.deltaY;");
sb.AppendLine("});");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
}
}

View File

@@ -0,0 +1,10 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
namespace SQLLinter.Infrastructure.Reporters;
public interface IReportFormatter
{
string Format(List<IRuleViolation> violations);
string Format(List<IRuleViolation> violations, BpmnDiagram diagram);
}

View File

@@ -1,53 +0,0 @@
using System.Text;
namespace SQLLinter.Infrastructure.Reporters;
public class MarkdownFileReporter : FileReporter
{
public override string GetContent()
{
var violations = Violations;
if (violations.Count == 0)
{
return "_Нет нарушений_";
}
// Группировка по файлу
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key); // сортировка файлов по имени
var sb = new StringBuilder();
foreach (var fileGroup in groupedByFile)
{
sb.AppendLine($"## Файл: {fileGroup.Key}");
sb.AppendLine();
// Группировка по severity внутри файла
var groupedBySeverity = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key); // сначала Error, потом Warning, потом Info
foreach (var severityGroup in groupedBySeverity)
{
sb.AppendLine($"### {severityGroup.Key}");
sb.AppendLine();
sb.AppendLine("| Строка | Колонка | Правило | Описание |");
sb.AppendLine("|--------|---------|---------|----------|");
foreach (var v in severityGroup
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
{
sb.AppendLine($"| {v.Line} | {v.Column} | {v.RuleName} | {v.Text} |");
}
sb.AppendLine();
}
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,59 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
using System.Text;
namespace SQLLinter.Infrastructure.Reporters;
public class MarkdownReportFormatter : IReportFormatter
{
public string Format(List<IRuleViolation> violations)
{
if (violations.Count == 0)
{
return "_Нет нарушений_";
}
// Группировка по файлу
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key); // сортировка файлов по имени
var sb = new StringBuilder();
foreach (var fileGroup in groupedByFile)
{
sb.AppendLine($"## Файл: {fileGroup.Key}");
sb.AppendLine();
// Группировка по severity внутри файла
var groupedBySeverity = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key); // сначала Error, потом Warning, потом Info
foreach (var severityGroup in groupedBySeverity)
{
sb.AppendLine($"### {severityGroup.Key}");
sb.AppendLine();
sb.AppendLine("| Строка | Колонка | Правило | Описание |");
sb.AppendLine("|--------|---------|---------|----------|");
foreach (var v in severityGroup
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
{
sb.AppendLine($"| {v.Line} | {v.Column} | {v.RuleName} | {v.Text} |");
}
sb.AppendLine();
}
}
return sb.ToString();
}
public string Format(List<IRuleViolation> violations, BpmnDiagram diagram)
{
throw new NotImplementedException();
}
}