Добавлены 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);
}
}