From c71e15c37f305ce36fa77e706bef555192ff3401 Mon Sep 17 00:00:00 2001 From: FrigaT Date: Fri, 26 Dec 2025 16:24:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=B1=D0=B8=D0=BB=D0=B4=D0=B5=D1=80=20BPMN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SQLLinter.CLI/Program.cs | 2 +- .../Infrastructure/Diagram/BpmnArrowType.cs | 12 + .../Infrastructure/Diagram/BpmnBuilder.cs | 882 +++++++++++++++--- .../Infrastructure/Diagram/BpmnDiagram.cs | 13 + .../Diagram/BpmnDiagramExtensions.cs | 34 + SQLLinter/Infrastructure/Diagram/BpmnEdge.cs | 19 + SQLLinter/Infrastructure/Diagram/BpmnNode.cs | 22 + .../Infrastructure/Diagram/BpmnNodeType.cs | 20 + .../Infrastructure/Diagram/BpmnProcess.cs | 19 + .../Infrastructure/Diagram/MermaidRenderer.cs | 120 +-- .../Diagram/SqlDiagramProcessor.cs | 3 +- .../Infrastructure/Reporters/HTMLReporter.cs | 0 .../Reporters/MarkdownFileReporter.cs | 0 .../Reporters/Static/HtmlFormatter.js | 4 +- 14 files changed, 912 insertions(+), 238 deletions(-) create mode 100644 SQLLinter/Infrastructure/Diagram/BpmnArrowType.cs create mode 100644 SQLLinter/Infrastructure/Diagram/BpmnDiagram.cs create mode 100644 SQLLinter/Infrastructure/Diagram/BpmnDiagramExtensions.cs create mode 100644 SQLLinter/Infrastructure/Diagram/BpmnEdge.cs create mode 100644 SQLLinter/Infrastructure/Diagram/BpmnNode.cs create mode 100644 SQLLinter/Infrastructure/Diagram/BpmnNodeType.cs create mode 100644 SQLLinter/Infrastructure/Diagram/BpmnProcess.cs delete mode 100644 SQLLinter/Infrastructure/Reporters/HTMLReporter.cs delete mode 100644 SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs diff --git a/SQLLinter.CLI/Program.cs b/SQLLinter.CLI/Program.cs index 9ee73ca..f7056e2 100644 --- a/SQLLinter.CLI/Program.cs +++ b/SQLLinter.CLI/Program.cs @@ -54,7 +54,7 @@ namespace SQLLinter.CLI var diagramer = new Diagramer(bpmn, fragmentBuilder, sqlStreamReaderBuilder); - using (StreamReader reader = new StreamReader(@"C:\Users\frost\Downloads\Telegram Desktop\tdostdetail.sql")) + using (StreamReader reader = new StreamReader(@"C:\Users\frost\Downloads\Telegram Desktop\test.sql")) { linter.Run("test.sql", reader.BaseStream); diagramer.Run("test.sql", reader.BaseStream); diff --git a/SQLLinter/Infrastructure/Diagram/BpmnArrowType.cs b/SQLLinter/Infrastructure/Diagram/BpmnArrowType.cs new file mode 100644 index 0000000..c3822b7 --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/BpmnArrowType.cs @@ -0,0 +1,12 @@ +namespace SQLLinter.Infrastructure.Diagram; + +/// +/// Типы связей между узлами BPMN +/// +public enum BpmnArrowType +{ + /// Обычная связь + Default, + /// Пунктирная связь (например, для вызова подпроцесса) + Dashed, +} diff --git a/SQLLinter/Infrastructure/Diagram/BpmnBuilder.cs b/SQLLinter/Infrastructure/Diagram/BpmnBuilder.cs index 05abed0..c51eb1e 100644 --- a/SQLLinter/Infrastructure/Diagram/BpmnBuilder.cs +++ b/SQLLinter/Infrastructure/Diagram/BpmnBuilder.cs @@ -1,51 +1,31 @@ -using Microsoft.SqlServer.TransactSql.ScriptDom; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Core; 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 Nodes { get; set; } = new(); - public List Edges { get; set; } = new(); -} - -public class BpmnDiagram -{ - public BpmnProcess Main { get; set; } = new BpmnProcess { Id = "Main", Name = "Main" }; - public List Subprocesses { get; set; } = new(); -} - +/// +/// Билдер диаграммы BPMN из T-SQL AST +/// public static class BpmnBuilder { - public static BpmnDiagram Build(TSqlFragment fragment) + /// + /// Сборка диаграммы BPMN из T-SQL AST + /// + /// + /// + public static BpmnDiagram Build(TSqlFragment fragment) => Build(fragment, new()); + + /// + /// Сборка диаграммы BPMN из T-SQL AST с использованием существующей диаграммы + /// + /// + /// + /// + public static BpmnDiagram Build(TSqlFragment fragment, BpmnDiagram diagram) { - var visitor = new BpmnVisitor(); + var visitor = new BpmnVisitor(diagram); fragment.Accept(visitor); + visitor.Diagram.AddMissingProcessEdges(); return visitor.Diagram; } @@ -54,160 +34,750 @@ public static class BpmnBuilder public BpmnDiagram Diagram { get; } = new BpmnDiagram(); private Dictionary _lastNodeByProcess = new(); - private BpmnProcess _currentProcess; + private Stack _processStack = new Stack(); private int _nodeCounter = 0; + private const int MaxConditionLength = 50; - public BpmnVisitor() + public BpmnVisitor() : this(new BpmnDiagram()) { } + + public BpmnVisitor(BpmnDiagram diagram) { - _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; + Diagram = diagram; + _nodeCounter = diagram.Processes.Sum(p => p.Nodes.Count()) + 1; } private string NewId() => "n" + (_nodeCounter++); - private void AddNode(BpmnProcess proc, BpmnNode node) + private void AddNode(BpmnProcess proc, BpmnNode node, string? edgeLabel = null) { + if (proc == null) return; + 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 }); + proc.Edges.Add(new BpmnEdge + { + From = last, + To = node.Id, + Label = edgeLabel ?? string.Empty + }); } _lastNodeByProcess[proc.Id] = node.Id; } - private void AddNodeToCurrent(string label, BpmnNodeType type) + private void AddNodeToCurrent(string label, BpmnNodeType type, Dictionary? properties = null) { - var node = new BpmnNode { Id = NewId(), Label = label, Type = type }; - AddNode(_currentProcess, node); + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + var node = new BpmnNode + { + Id = NewId(), + Label = label, + Type = type + }; + + if (properties != null) + { + node.Properties = new Dictionary(properties); + } + + AddNode(currentProcess, node); } - public override void Visit(TSqlScript node) + private void ProcessProcedureOrFunction(TSqlStatement node, string objectType) { - _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); + // Извлечение имени объекта на основе AST + var name = FindSchemaObjectName(node) ?? ($"{objectType}" + Diagram.Processes.Count); var pid = SanitizeId(name); - var proc = new BpmnProcess { Id = pid, Name = name }; + var proc = new BpmnProcess { Id = pid, Name = $"{name} ({objectType})" }; - // 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 }; + // создать начальный узел + var start = new BpmnNode { Id = pid + "_start", Label = "Начало", Type = BpmnNodeType.Start }; proc.Nodes.Add(start); - // set last node pointer to start _lastNodeByProcess[proc.Id] = start.Id; - Diagram.Subprocesses.Add(proc); + Diagram.Processes.Add(proc); - var old = _currentProcess; - _currentProcess = proc; - if (!_lastNodeByProcess.ContainsKey(proc.Id)) _lastNodeByProcess[proc.Id] = start.Id; + // Помещаем процесс в стек + _processStack.Push(proc); - base.Visit(node); + // Используем специальный visitor для поиска StatementList + var statementCollector = new StatementListCollector(); + node.Accept(statementCollector); - // after visiting procedure body add End node - var end = new BpmnNode { Id = pid + "_end", Label = "End", Type = BpmnNodeType.End }; + if (statementCollector.FoundStatementList != null) + { + statementCollector.FoundStatementList.Accept(this); + } + + // после посещения тела добавляем конечный узел + var end = new BpmnNode { Id = pid + "_end", Label = "Конец", Type = BpmnNodeType.End }; AddNode(proc, end); - _currentProcess = old; + // Убираем процесс из стека + _processStack.Pop(); } + private class StatementListCollector : TSqlFragmentVisitor + { + public StatementList? FoundStatementList { get; private set; } + + public override void Visit(StatementList node) + { + if (FoundStatementList == null) + { + FoundStatementList = node; + } + } + } + + public override void ExplicitVisit(CreateProcedureStatement node) => ProcessProcedureOrFunction(node, "процедура"); + public override void ExplicitVisit(CreateOrAlterProcedureStatement node) => ProcessProcedureOrFunction(node, "процедура"); + public override void ExplicitVisit(AlterProcedureStatement node) => ProcessProcedureOrFunction(node, "процедура"); + + public override void ExplicitVisit(CreateFunctionStatement node) => ProcessProcedureOrFunction(node, "функция"); + public override void ExplicitVisit(CreateOrAlterFunctionStatement node) => ProcessProcedureOrFunction(node, "функция"); + public override void ExplicitVisit(AlterFunctionStatement node) => ProcessProcedureOrFunction(node, "функция"); + + public override void ExplicitVisit(CreateTriggerStatement node) => ProcessProcedureOrFunction(node, "триггер"); + public override void ExplicitVisit(CreateOrAlterTriggerStatement node) => ProcessProcedureOrFunction(node, "триггер"); + public override void ExplicitVisit(AlterTriggerStatement node) => ProcessProcedureOrFunction(node, "триггер"); + public override void Visit(ExecuteStatement node) { - // AST-based extraction for called proc + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + // Извлечение на основе AST для вызываемого процесса var called = FindProcedureNameFromExecute(node); - var label = called != null ? $"EXEC {called}" : node.ToString(); + var label = called != null ? $"EXEC {called}" : "EXEC"; - // task in current process - var task = new BpmnNode { Id = NewId(), Label = label, Type = BpmnNodeType.Task }; - AddNode(_currentProcess, task); + // задача в текущем процессе + var task = new BpmnNode { Id = NewId(), Label = label!, Type = BpmnNodeType.Subprocess, SubprocessId = called is null ? string.Empty : SanitizeId(called), }; + AddNode(currentProcess, task); - // if calling known subprocess, add dashed edge from this node to subprocess start - if (called != null) + // Обходим параметры вызова + if (node.ExecuteSpecification != 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 }); - } + node.ExecuteSpecification.Accept(this); + } + } + + public override void Visit(FunctionCall node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + // Извлекаем имя функции + var functionName = node.FunctionName?.Value ?? "unknown"; + var schemaPrefix = ""; + + // Проверяем, есть ли схема в имени через MultiPartIdentifier + if (node.CallTarget is MultiPartIdentifierCallTarget multiPartCallTarget) + { + schemaPrefix = GetMultiPartIdentifierName(multiPartCallTarget.MultiPartIdentifier); } - base.Visit(node); + var fullFunctionName = !string.IsNullOrEmpty(schemaPrefix) + ? $"{schemaPrefix}.{functionName}" + : functionName; + + // Проверяем, является ли это пользовательской функцией + var isUserFunction = IsUserDefinedFunction(fullFunctionName); + + var label = isUserFunction ? $"FUNC {fullFunctionName}" : $"{functionName}()"; + var nodeType = isUserFunction ? BpmnNodeType.Subprocess : BpmnNodeType.Task; + + var funcNode = new BpmnNode + { + Id = NewId(), + Label = label, + Type = nodeType, + SubprocessId = isUserFunction ? SanitizeId(fullFunctionName) : string.Empty, + }; + AddNode(currentProcess, funcNode); + } + + private bool IsUserDefinedFunction(string functionName) + { + // Проверяем, есть ли такая функция в нашей диаграмме + var isInDiagram = Diagram.Processes.Any(p => + p.Name.Contains(functionName, StringComparison.OrdinalIgnoreCase)); + + // Если функция есть в диаграмме, это пользовательская функция + if (isInDiagram) return true; + + // Если функция не встроенная и имеет префикс схемы (например, dbo.), считаем ее пользовательской + if (functionName.Contains('.') && !Constants.SystemFunctions.Contains(functionName.Split('.')[1])) + { + return true; + } + + return !Constants.SystemFunctions.Contains(functionName); } public override void Visit(IfStatement node) { - var gid = new BpmnNode { Id = NewId(), Label = "IF", Type = BpmnNodeType.Gateway }; - AddNode(_currentProcess, gid); + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); - // then / else placeholders + // Извлекаем условие IF + var conditionText = GetConditionText(node.Predicate); + var truncatedCondition = TruncateText(conditionText, MaxConditionLength); + + var properties = new Dictionary + { + ["condition"] = conditionText, + ["condition_display"] = truncatedCondition + }; + + // Создаем узел шлюза для IF с условием + var gid = new BpmnNode + { + Id = NewId(), + Label = $"IF ({truncatedCondition})", + Type = BpmnNodeType.Gateway, + Properties = properties + }; + AddNode(currentProcess, gid); + + // Создаем узел ENDIF для объединения веток + var endid = new BpmnNode { Id = NewId(), Label = "ENDIF", Type = BpmnNodeType.Gateway }; + currentProcess.Nodes.Add(endid); + + // Обработка THEN ветки if (node.ThenStatement != null) { + _lastNodeByProcess[currentProcess.Id] = gid.Id; 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 }); + AddNode(currentProcess, thenNode, edgeLabel: $"Да"); + + // Ручной обход THEN ветки + TraverseStatement(node.ThenStatement, currentProcess); + + // После обработки THEN ветки добавляем край к ENDIF + if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var thenLastNode)) + { + currentProcess.Edges.Add(new BpmnEdge + { + From = thenLastNode, + To = endid.Id + }); + } } + // Обработка ELSE ветки if (node.ElseStatement != null) { + _lastNodeByProcess[currentProcess.Id] = gid.Id; 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 }); + AddNode(currentProcess, elseNode, edgeLabel: $"Нет"); + + // Ручной обход ELSE ветки + TraverseStatement(node.ElseStatement, currentProcess); + + // После обработки ELSE ветки добавляем край к ENDIF + if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var elseLastNode)) + { + currentProcess.Edges.Add(new BpmnEdge + { + From = elseLastNode, + To = endid.Id + }); + } } - base.Visit(node); + // Восстанавливаем последний узел после обработки IF + _lastNodeByProcess[currentProcess.Id] = endid.Id; + } + + public override void Visit(MergeStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + var target = node.MergeSpecification.Target != null ? FindTargetNameForDml(node.MergeSpecification.Target) : "?"; + var label = $"MERGE -> {target}"; + + AddNodeToCurrent(label, BpmnNodeType.Task); + } + + public override void Visit(SetVariableStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + // Получаем имя переменной + var variableName = node.Variable?.Name ?? "?"; + + // Получаем текст выражения + var expressionText = GetExpressionText(node.Expression); + var truncatedExpression = TruncateText(expressionText, MaxConditionLength); + + // Определяем оператор присваивания на основе AssignmentKind + string assignmentOp = "="; + + assignmentOp = node.AssignmentKind switch + { + AssignmentKind.Equals => "=", + AssignmentKind.AddEquals => "+=", + AssignmentKind.SubtractEquals => "-=", + AssignmentKind.MultiplyEquals => "*=", + AssignmentKind.DivideEquals => "/=", + AssignmentKind.ModEquals => "%=", + AssignmentKind.BitwiseAndEquals => "&=", + AssignmentKind.BitwiseOrEquals => "|=", + AssignmentKind.BitwiseXorEquals => "^=", + _ => "=" + }; + + // Проверяем, содержит ли выражение вызов функции + var containsFunctionCall = false; + if (node.Expression != null) + { + var functionFinder = new FunctionCallFinder(); + node.Expression.Accept(functionFinder); + containsFunctionCall = functionFinder.FunctionCalls.Any(fc => IsUserDefinedFunction(fc.FunctionName?.Value ?? "")); + } + + var label = $"SET {variableName} {assignmentOp} {truncatedExpression}"; + + var properties = new Dictionary + { + ["variable"] = variableName, + ["expression"] = expressionText, + ["expression_display"] = truncatedExpression, + ["assignment_kind"] = node.AssignmentKind.ToString(), + ["contains_function_call"] = containsFunctionCall.ToString() + }; + + AddNodeToCurrent(label, BpmnNodeType.Task, properties); + + // Обходим выражение для поиска вызовов функций + if (node.Expression != null) + { + node.Expression.Accept(this); + } + } + + public override void Visit(SetCommandStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + var commandText = GetCommandText(node); + var truncatedCommand = TruncateText(commandText, MaxConditionLength); + + var label = $"SET {truncatedCommand}"; + + var properties = new Dictionary + { + ["command"] = commandText, + ["command_display"] = truncatedCommand + }; + + AddNodeToCurrent(label, BpmnNodeType.Task, properties); + } + + public override void Visit(GoToStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + var label = node.LabelName?.Value ?? "?"; + var gotoNode = new BpmnNode + { + Id = NewId(), + Label = $"GOTO {label}", + Type = BpmnNodeType.Task + }; + AddNode(currentProcess, gotoNode); + } + + public override void Visit(TryCatchStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + var tryNode = new BpmnNode { Id = NewId(), Label = "TRY", Type = BpmnNodeType.Gateway }; + var catchNode = new BpmnNode { Id = NewId(), Label = "CATCH", Type = BpmnNodeType.Gateway }; + var endTryCatch = new BpmnNode { Id = NewId(), Label = "END TRY/CATCH", Type = BpmnNodeType.Gateway }; + + AddNode(currentProcess, tryNode); + currentProcess.Nodes.Add(catchNode); + currentProcess.Nodes.Add(endTryCatch); + + // TRY блок + if (node.TryStatements != null) + { + _lastNodeByProcess[currentProcess.Id] = tryNode.Id; + foreach (var statement in node.TryStatements.Statements) + { + statement.Accept(this); + } + + // Ребро от последнего узла TRY к endTryCatch + if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var tryLast)) + { + currentProcess.Edges.Add(new BpmnEdge + { + From = tryLast, + To = endTryCatch.Id + }); + } + } + + // CATCH блок + if (node.CatchStatements != null) + { + _lastNodeByProcess[currentProcess.Id] = catchNode.Id; + currentProcess.Edges.Add(new BpmnEdge + { + From = tryNode.Id, + To = catchNode.Id, + Label = "Ошибка" + }); + + foreach (var statement in node.CatchStatements.Statements) + { + statement.Accept(this); + } + + // Ребро от последнего узла CATCH к endTryCatch + if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var catchLast)) + { + currentProcess.Edges.Add(new BpmnEdge + { + From = catchLast, + To = endTryCatch.Id + }); + } + } + + _lastNodeByProcess[currentProcess.Id] = endTryCatch.Id; + } + + public override void Visit(WhileStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + // Извлекаем условие WHILE + var conditionText = GetConditionText(node.Predicate); + var truncatedCondition = TruncateText(conditionText, MaxConditionLength); + + var properties = new Dictionary + { + ["condition"] = conditionText, + ["condition_display"] = truncatedCondition, + ["loop_type"] = "WHILE" + }; + + // Узел начала цикла + var loopStart = new BpmnNode + { + Id = NewId(), + Label = $"WHILE ({truncatedCondition})", + Type = BpmnNodeType.Hexagon, + Properties = properties + }; + AddNode(currentProcess, loopStart); + + // Сохраняем текущий последний узел + var savedLastNode = _lastNodeByProcess[currentProcess.Id]; + + // Обработка тела цикла + if (node.Statement != null) + { + // Добавляем ребро от gateway к первому узлу тела цикла + var bodyStartNode = new BpmnNode + { + Id = NewId(), + Label = "Тело цикла", + Type = BpmnNodeType.Task + }; + AddNode(currentProcess, bodyStartNode, "Вход"); + + // Обход тела цикла + TraverseStatement(node.Statement, currentProcess); + + // Добавляем ребро возврата в начало цикла от последнего узла тела + if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var loopBodyLast)) + { + currentProcess.Edges.Add(new BpmnEdge + { + From = loopBodyLast, + To = loopStart.Id, + Label = "Повтор" + }); + } + } + + // Узел конца цикла (выход, когда условие ложно) + var loopEnd = new BpmnNode { Id = NewId(), Label = "END WHILE", Type = BpmnNodeType.Gateway }; + currentProcess.Nodes.Add(loopEnd); + + // Ребро выхода из цикла + currentProcess.Edges.Add(new BpmnEdge + { + From = loopStart.Id, + To = loopEnd.Id, + Label = "Выход" + }); + + // Восстанавливаем последний узел + _lastNodeByProcess[currentProcess.Id] = loopEnd.Id; + } + + public override void Visit(BreakStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + var breakNode = new BpmnNode + { + Id = NewId(), + Label = "BREAK", + Type = BpmnNodeType.Task + }; + AddNode(currentProcess, breakNode); + } + + public override void Visit(ContinueStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + var continueNode = new BpmnNode + { + Id = NewId(), + Label = "CONTINUE", + Type = BpmnNodeType.Task + }; + AddNode(currentProcess, continueNode); + } + + public override void Visit(ReturnStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + var label = "RETURN"; + if (node.Expression != null) + { + var exprText = GetExpressionText(node.Expression); + var truncatedExpr = TruncateText(exprText, MaxConditionLength); + label = $"RETURN {truncatedExpr}"; + } + + var returnNode = new BpmnNode + { + Id = NewId(), + Label = label, + Type = BpmnNodeType.Task + }; + AddNode(currentProcess, returnNode); + } + + public override void Visit(DeclareVariableStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + var declarations = new List(); + foreach (var declaration in node.Declarations) + { + var varName = declaration.VariableName?.Value ?? "?"; + var dataType = declaration.DataType?.Name?.BaseIdentifier?.Value ?? "?"; + declarations.Add($"{varName} {dataType}"); + } + + var label = "DECLARE"; + if (declarations.Count > 0) + { + var declText = string.Join(", ", declarations); + var truncatedDecl = TruncateText(declText, MaxConditionLength); + label = $"DECLARE {truncatedDecl}"; + } + + AddNodeToCurrent(label, BpmnNodeType.Task); + } + + public override void Visit(BeginEndBlockStatement node) + { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + + // Просто обходим все операторы внутри блока + if (node.StatementList != null) + { + foreach (var statement in node.StatementList.Statements) + { + statement.Accept(this); + } + } + } + + private void TraverseStatement(TSqlFragment statement, BpmnProcess process) + { + if (statement is StatementList statementList) + { + foreach (var stmt in statementList.Statements) + { + stmt.Accept(this); + } + } + else if (statement is BeginEndBlockStatement beginEnd) + { + // Для блоков BEGIN...END обходим внутренние statements + if (beginEnd.StatementList != null) + { + foreach (var stmt in beginEnd.StatementList.Statements) + { + stmt.Accept(this); + } + } + } + else + { + statement.Accept(this); + } } public override void Visit(SelectStatement node) { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + var label = "SELECT"; if (node.QueryExpression is QuerySpecification qs && qs.SelectElements != null) { - label = $"SELECT ({qs.SelectElements.Count})"; + label = $"SELECT ({qs.SelectElements.Count} полей)"; } + AddNodeToCurrent(label, BpmnNodeType.Task); - base.Visit(node); + + // Обходим все элементы SELECT для поиска вызовов функций + if (node.QueryExpression != null) + { + node.QueryExpression.Accept(this); + } } public override void Visit(InsertStatement node) { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + var target = FindTargetNameForDml(node); - var label = "INSERT" + (string.IsNullOrEmpty(target) ? string.Empty : " -> " + target); + var label = "INSERT" + (string.IsNullOrEmpty(target) ? string.Empty : $" -> {target}"); AddNodeToCurrent(label, BpmnNodeType.Task); - base.Visit(node); } public override void Visit(UpdateStatement node) { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + var target = FindTargetNameForDml(node); - var label = "UPDATE" + (string.IsNullOrEmpty(target) ? string.Empty : " -> " + target); + var label = "UPDATE" + (string.IsNullOrEmpty(target) ? string.Empty : $" -> {target}"); AddNodeToCurrent(label, BpmnNodeType.Task); - base.Visit(node); } public override void Visit(DeleteStatement node) { + if (_processStack.Count == 0) return; + var currentProcess = _processStack.Peek(); + var target = FindTargetNameForDml(node); - var label = "DELETE" + (string.IsNullOrEmpty(target) ? string.Empty : " -> " + target); + var label = "DELETE" + (string.IsNullOrEmpty(target) ? string.Empty : $" -> {target}"); AddNodeToCurrent(label, BpmnNodeType.Task); - base.Visit(node); + } + + // Вспомогательные методы для извлечения текста + private string GetConditionText(BooleanExpression expression) + { + if (expression == null) return ""; + + try + { + // Пробуем получить через ScriptTokenStream + var tokens = expression.ScriptTokenStream; + if (tokens != null && expression.FirstTokenIndex >= 0 && + expression.LastTokenIndex >= expression.FirstTokenIndex) + { + var text = string.Join("", + tokens.Skip(expression.FirstTokenIndex) + .Take(expression.LastTokenIndex - expression.FirstTokenIndex + 1) + .Select(t => t.Text)); + return text.Trim(); + } + + // Fallback: используем ToString() + return expression.ToString()?.Trim() ?? ""; + } + catch + { + return ""; + } + } + + private string GetExpressionText(ScalarExpression expression) + { + if (expression == null) return ""; + + try + { + var tokens = expression.ScriptTokenStream; + if (tokens != null && expression.FirstTokenIndex >= 0 && expression.LastTokenIndex >= expression.FirstTokenIndex) + { + var text = string.Join("", + tokens.Skip(expression.FirstTokenIndex) + .Take(expression.LastTokenIndex - expression.FirstTokenIndex + 1) + .Select(t => t.Text)); + return text.Trim(); + } + } + catch + { + // Игнорируем ошибки парсинга + } + + return expression.ToString() ?? ""; + } + + private string GetCommandText(SetCommandStatement node) + { + if (node == null) return ""; + + try + { + var tokens = node.ScriptTokenStream; + if (tokens != null && node.FirstTokenIndex >= 0 && node.LastTokenIndex >= node.FirstTokenIndex) + { + var text = string.Join("", + tokens.Skip(node.FirstTokenIndex) + .Take(node.LastTokenIndex - node.FirstTokenIndex + 1) + .Select(t => t.Text)); + return text.Trim(); + } + } + catch + { + // Игнорируем ошибки парсинга + } + + return node.ToString() ?? ""; + } + + private string TruncateText(string text, int maxLength) + { + if (string.IsNullOrEmpty(text)) return ""; + if (text.Length <= maxLength) return text; + + return text.Substring(0, maxLength - 3) + "..."; } // AST helpers @@ -236,52 +806,40 @@ public static class BpmnBuilder 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(); - } + FoundName = GetSchemaObjectName(node); } base.Visit(node); } public override void Visit(ProcedureReferenceName node) { - if (FoundName == null) + if (FoundName == null && node.ProcedureReference?.Name != 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(); - } + FoundName = GetSchemaObjectName(node.ProcedureReference.Name); } base.Visit(node); } + + private static string GetSchemaObjectName(SchemaObjectName node) + { + try + { + if (node.Identifiers != null && node.Identifiers.Count > 0) + { + return string.Join('.', node.Identifiers.Select(id => "[" + id.Value + "]")); + } + return "[" + (node.ToString() ?? "unknown") + "]"; + } + catch + { + return "[unknown]"; + } + } } private class ProcedureReferenceFinder : TSqlFragmentVisitor @@ -296,14 +854,14 @@ public static class BpmnBuilder if (node.Name != null) { if (node.Name.Identifiers != null && node.Name.Identifiers.Count > 0) - FoundName = string.Join('.', node.Name.Identifiers.Select(id => id.Value)); + FoundName = string.Join('.', node.Name.Identifiers.Select(id => "[" + id.Value + "]")); else - FoundName = node.Name.ToString(); + FoundName = "[" + node.Name.ToString() + "]"; } } catch { - FoundName = node.ToString(); + FoundName = "[" + node.Name.ToString() + "]"; } } base.Visit(node); @@ -338,16 +896,16 @@ public static class BpmnBuilder try { if (node.SchemaObject != null && node.SchemaObject.Identifiers != null && node.SchemaObject.Identifiers.Count > 0) - FoundName = string.Join('.', node.SchemaObject.Identifiers.Select(id => id.Value)); + FoundName = string.Join('.', node.SchemaObject.Identifiers.Select(id => "[" + id.Value + "]")); else - FoundName = node.SchemaObject?.ToString() ?? node.ToString(); + FoundName = "[" + node.SchemaObject?.ToString() ?? node.ToString() + "]"; if (node.Alias != null && !string.IsNullOrWhiteSpace(node.Alias.Value)) - FoundName += " AS " + node.Alias.Value; + FoundName += " AS [" + node.Alias.Value + "]"; } catch { - FoundName = node.SchemaObject?.ToString() ?? node.ToString(); + FoundName = "[" + node.SchemaObject?.ToString() ?? node.ToString() + "]"; } } @@ -398,5 +956,25 @@ public static class BpmnBuilder } return sb.ToString(); } + + private class FunctionCallFinder : TSqlFragmentVisitor + { + public List FunctionCalls { get; } = new List(); + + public override void Visit(FunctionCall node) + { + FunctionCalls.Add(node); + base.Visit(node); + } + } + + private string GetMultiPartIdentifierName(MultiPartIdentifier identifier) + { + if (identifier != null && identifier.Identifiers != null && identifier.Identifiers.Count > 0) + { + return string.Join(".", identifier.Identifiers.Select(id => id.Value)); + } + return ""; + } } -} +} \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Diagram/BpmnDiagram.cs b/SQLLinter/Infrastructure/Diagram/BpmnDiagram.cs new file mode 100644 index 0000000..90892db --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/BpmnDiagram.cs @@ -0,0 +1,13 @@ +namespace SQLLinter.Infrastructure.Diagram; + +/// +/// Диаграмма BPMN, объединяющая процессы +/// +public class BpmnDiagram +{ + /// Процессы диаграммы + public List Processes { get; set; } = new(); + + /// Глобальные связи между процессами + public List GlobalEdges { get; set; } = new(); +} diff --git a/SQLLinter/Infrastructure/Diagram/BpmnDiagramExtensions.cs b/SQLLinter/Infrastructure/Diagram/BpmnDiagramExtensions.cs new file mode 100644 index 0000000..1738ae0 --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/BpmnDiagramExtensions.cs @@ -0,0 +1,34 @@ +namespace SQLLinter.Infrastructure.Diagram; + +/// +/// Расширения для диаграммы BPMN +/// +public static class BpmnDiagramExtensions +{ + /// + /// Добавления отсутсвующих связей между процессами и их подпроцессами + /// + /// + public static void AddMissingProcessEdges(this BpmnDiagram diagram) + { + foreach (var subprocess in diagram.Processes + .SelectMany(p => p.Nodes.Select(n => new { node = n, procId = p.Id })) + .Where(n => n.node.Type == BpmnNodeType.Subprocess && !string.IsNullOrEmpty(n.node.SubprocessId) && n.node.SubprocessId != n.procId) + .Select(n => n.node) + ) + { + if (diagram.Processes.Any(p => p.Id == subprocess.SubprocessId) + && !diagram.GlobalEdges.Any(t => t.From == subprocess.Id && t.To == subprocess.SubprocessId) + ) + { + // Добавить пунктирный край от узла подпроцесса к началу подпроцесса + diagram.GlobalEdges.Add(new BpmnEdge + { + From = subprocess.Id, + To = subprocess.SubprocessId + "_start", + ArrowType = BpmnArrowType.Dashed, + }); + } + } + } +} diff --git a/SQLLinter/Infrastructure/Diagram/BpmnEdge.cs b/SQLLinter/Infrastructure/Diagram/BpmnEdge.cs new file mode 100644 index 0000000..b968d32 --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/BpmnEdge.cs @@ -0,0 +1,19 @@ +namespace SQLLinter.Infrastructure.Diagram; + +/// +/// Связь между узлами BPMN +/// +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 BpmnArrowType ArrowType { get; set; } = BpmnArrowType.Default; +} diff --git a/SQLLinter/Infrastructure/Diagram/BpmnNode.cs b/SQLLinter/Infrastructure/Diagram/BpmnNode.cs new file mode 100644 index 0000000..7f669f7 --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/BpmnNode.cs @@ -0,0 +1,22 @@ +namespace SQLLinter.Infrastructure.Diagram; + +/// +/// Узел BPMN +/// +public class BpmnNode +{ + /// Уникальный идентификатор узла + public string Id { get; set; } = string.Empty; + + /// Метка/название узла + public string Label { get; set; } = string.Empty; + + /// Тип узла + public BpmnNodeType Type { get; set; } + + /// Идентификатор подпроцесса (если тип узла - Subprocess) + public string SubprocessId { get; set; } = string.Empty; + + /// Дополнительные свойства узла + public Dictionary Properties { get; set; } = new(); +} diff --git a/SQLLinter/Infrastructure/Diagram/BpmnNodeType.cs b/SQLLinter/Infrastructure/Diagram/BpmnNodeType.cs new file mode 100644 index 0000000..e03032e --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/BpmnNodeType.cs @@ -0,0 +1,20 @@ +namespace SQLLinter.Infrastructure.Diagram; + +/// +/// Типы узлов BPMN +/// +public enum BpmnNodeType +{ + /// Стартовый узел процесса + Start, + /// Конечный узел процесса + End, + /// Задача + Task, + /// Шлюз (разветвление/объединение) + Gateway, + /// Шлюз (разветвление/объединение) + Hexagon, + /// Подпроцесс (вызов другого процесса) + Subprocess, +} diff --git a/SQLLinter/Infrastructure/Diagram/BpmnProcess.cs b/SQLLinter/Infrastructure/Diagram/BpmnProcess.cs new file mode 100644 index 0000000..d92e169 --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/BpmnProcess.cs @@ -0,0 +1,19 @@ +namespace SQLLinter.Infrastructure.Diagram; + +/// +/// Процесс BPMN +/// +public class BpmnProcess +{ + /// Уникальный идентификатор процесса + public string Id { get; set; } = string.Empty; + + /// Название процесса + public string Name { get; set; } = string.Empty; + + /// Узлы процесса + public List Nodes { get; set; } = new(); + + /// Связи процесса + public List Edges { get; set; } = new(); +} diff --git a/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs b/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs index 9c1f667..839bcbd 100644 --- a/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs +++ b/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; namespace SQLLinter.Infrastructure.Diagram; @@ -9,114 +9,72 @@ public static class MermaidRenderer 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) + // ----------------------------------------- + // Рендер каждого процесса как subgraph + // ----------------------------------------- + foreach (var proc in diagram.Processes) { var procId = SanitizeId(proc.Id); sb.AppendLine($" subgraph {procId} [\"{Escape(proc.Name)}\"]"); - sb.AppendLine($" direction TB"); + sb.AppendLine(" direction TB"); - 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) + foreach (var node in proc.Nodes) { var nid = SanitizeId(node.Id); var label = Escape(node.Label); - sb.AppendLine($" {nid}[\"{label}\"]"); + + label = node.Type switch + { + BpmnNodeType.Start => $@"((""{label}""))", + BpmnNodeType.Task => $@"[""{label}""]", + BpmnNodeType.Subprocess => $@"[[""{label}""]]", + BpmnNodeType.Gateway => $@"{{""{label}""}}", + BpmnNodeType.Hexagon => $@"{{{{""{label}""}}}}", + BpmnNodeType.End => $@"((""{label}""))", + _ => $@">""{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 + // Edges внутри процесса foreach (var e in proc.Edges) { var from = SanitizeId(e.From); var to = SanitizeId(e.To); - var arrow = e.Dashed ? "-.->" : "-->"; + var arrow = e.ArrowType switch + { + BpmnArrowType.Dashed => "-.->", + _ => "-->", + }; var lbl = string.IsNullOrWhiteSpace(e.Label) ? string.Empty : $" |{Escape(e.Label)}|"; - sb.AppendLine($" {from} {arrow} {to}{lbl}"); + + sb.AppendLine($" {from} {arrow}{lbl} {to}"); } sb.AppendLine(" end"); sb.AppendLine(); } - // main edges - foreach (var e in diagram.Main.Edges) + // ----------------------------------------- + // Рендер глобальных связей между процессами + // ----------------------------------------- + foreach (var e in diagram.GlobalEdges) { var from = SanitizeId(e.From); var to = SanitizeId(e.To); - var arrow = e.Dashed ? "-.->" : "-->"; + var arrow = e.ArrowType switch + { + BpmnArrowType.Dashed => "-.->", + _ => "-->", + }; var lbl = string.IsNullOrWhiteSpace(e.Label) ? string.Empty : $" |{Escape(e.Label)}|"; - sb.AppendLine($" {from} {arrow} {to}{lbl}"); + sb.AppendLine($" {from} {arrow}{lbl} {to}"); } 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(); - } - private static string Escape(string s) { if (s == null) return string.Empty; @@ -134,4 +92,4 @@ public static class MermaidRenderer } return sb.ToString(); } -} +} \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Diagram/SqlDiagramProcessor.cs b/SQLLinter/Infrastructure/Diagram/SqlDiagramProcessor.cs index 9876378..a09f015 100644 --- a/SQLLinter/Infrastructure/Diagram/SqlDiagramProcessor.cs +++ b/SQLLinter/Infrastructure/Diagram/SqlDiagramProcessor.cs @@ -57,8 +57,7 @@ public class SqlDiagramProcessor : ISqlDiagramProcessor return; } - var diagramm = BpmnBuilder.Build(fragment); - _bpmnDiagram.Subprocesses.Add(diagramm.Main); + BpmnBuilder.Build(fragment, _bpmnDiagram); } private Stream GetFileContents(string filePath) diff --git a/SQLLinter/Infrastructure/Reporters/HTMLReporter.cs b/SQLLinter/Infrastructure/Reporters/HTMLReporter.cs deleted file mode 100644 index e69de29..0000000 diff --git a/SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs b/SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs deleted file mode 100644 index e69de29..0000000 diff --git a/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js b/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js index 33753b3..dca362d 100644 --- a/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js +++ b/SQLLinter/Infrastructure/Reporters/Static/HtmlFormatter.js @@ -93,8 +93,8 @@ class DiagramViewer { this.scale = 1; this.tx = 0; this.ty = 0; - this.minScale = 0.1; - this.maxScale = 6; + this.minScale = 0.3; + this.maxScale = 12; this.originalViewBox = null; this.minimapSvg = null;