Files
SQLLint/SQLLinter/Infrastructure/Diagram/BpmnBuilder.cs
2025-12-25 12:59:20 +03:00

403 lines
14 KiB
C#

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