diff --git a/SQLLinter.CLI/Program.cs b/SQLLinter.CLI/Program.cs index a5d631c..9ee73ca 100644 --- a/SQLLinter.CLI/Program.cs +++ b/SQLLinter.CLI/Program.cs @@ -1,4 +1,6 @@ using SQLLinter.Infrastructure.Configuration; +using SQLLinter.Infrastructure.Diagram; +using SQLLinter.Infrastructure.Parser; using SQLLinter.Infrastructure.Reporters; namespace SQLLinter.CLI @@ -7,7 +9,7 @@ namespace SQLLinter.CLI { static void Main(string[] args) { - var rep = new HTMLReporter(); + var rep = new Reporter(); var con = new Config() { CompatibilityLevel = 170, @@ -43,16 +45,27 @@ namespace SQLLinter.CLI } }; - var linter = new Linter(con, rep); + //var linter = new Linter(con, rep); + var fragmentBuilder = new FragmentBuilder(rep, con.CompatibilityLevel); + var sqlStreamReaderBuilder = new SqlStreamReaderBuilder(); + var bpmn = new BpmnDiagram(); + + var linter = new Linter(con, rep, fragmentBuilder, sqlStreamReaderBuilder); + + var diagramer = new Diagramer(bpmn, fragmentBuilder, sqlStreamReaderBuilder); using (StreamReader reader = new StreamReader(@"C:\Users\frost\Downloads\Telegram Desktop\tdostdetail.sql")) { linter.Run("test.sql", reader.BaseStream); + diagramer.Run("test.sql", reader.BaseStream); } //linter.Run(@"C:\Users\frost\Desktop\DISTR-2599\test.sql"); - rep.SaveReport(@"C:\Users\frost\Downloads\Telegram Desktop\test.html"); + var formatter = new HtmlReportFormatter(); + var content = formatter.Format(rep.Violations, bpmn); + + File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test.html", content); } } } diff --git a/SQLLinter/Core/Interfaces/ISqlDiagram.cs b/SQLLinter/Core/Interfaces/ISqlDiagram.cs new file mode 100644 index 0000000..766df7a --- /dev/null +++ b/SQLLinter/Core/Interfaces/ISqlDiagram.cs @@ -0,0 +1,9 @@ + +namespace SQLLinter.Core.Interfaces; + +public interface ISqlDiagramProcessor +{ + void ProcessList(List filePaths); + void ProcessList(Dictionary files); + void ProcessPath(string path); +} diff --git a/SQLLinter/Diagramer.cs b/SQLLinter/Diagramer.cs new file mode 100644 index 0000000..de31921 --- /dev/null +++ b/SQLLinter/Diagramer.cs @@ -0,0 +1,41 @@ +using SQLLinter.Common.Helpers; +using SQLLinter.Core.Interfaces; +using SQLLinter.Infrastructure.Diagram; +using SQLLinter.Infrastructure.Interfaces; + +namespace SQLLinter; + +public class Diagramer +{ + private ISqlDiagramProcessor _diagramProcessor; + + public Diagramer(BpmnDiagram bpmnDiagram + , IFragmentBuilder fragmentBuilder + , ISqlStreamReaderBuilder sqlStreamReaderBuilder + ) + { + _diagramProcessor = new SqlDiagramProcessor(fragmentBuilder, bpmnDiagram, sqlStreamReaderBuilder); + } + + public void Run(string filePath) + { + this.Run([filePath]); + } + + public void Run(List filePaths) + { + List files = FileHelpers.FindFilesWithMask(filePaths); + + _diagramProcessor.ProcessList(filePaths); + } + + public void Run(string fileName, Stream fileReader) + { + Run(new Dictionary { [fileName] = fileReader }); + } + + public void Run(Dictionary files) + { + _diagramProcessor.ProcessList(files); + } +} diff --git a/SQLLinter/Infrastructure/Diagram/BpmnBuilder.cs b/SQLLinter/Infrastructure/Diagram/BpmnBuilder.cs new file mode 100644 index 0000000..05abed0 --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/BpmnBuilder.cs @@ -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 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(); +} + +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 _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(); + } + } +} diff --git a/SQLLinter/Infrastructure/Diagram/FragmentDiagramBuilder.cs b/SQLLinter/Infrastructure/Diagram/FragmentDiagramBuilder.cs new file mode 100644 index 0000000..0337687 --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/FragmentDiagramBuilder.cs @@ -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(); + } +} diff --git a/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs b/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs new file mode 100644 index 0000000..f243d7a --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/MermaidRenderer.cs @@ -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("
\n" + content + "\n
"); + + 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(); + } +} diff --git a/SQLLinter/Infrastructure/Diagram/SqlDiagramProcessor.cs b/SQLLinter/Infrastructure/Diagram/SqlDiagramProcessor.cs new file mode 100644 index 0000000..9876378 --- /dev/null +++ b/SQLLinter/Infrastructure/Diagram/SqlDiagramProcessor.cs @@ -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 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 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); + } +} diff --git a/SQLLinter/Infrastructure/Parser/SqlFileProcessor.cs b/SQLLinter/Infrastructure/Parser/SqlFileProcessor.cs index 0c4333c..d85387a 100644 --- a/SQLLinter/Infrastructure/Parser/SqlFileProcessor.cs +++ b/SQLLinter/Infrastructure/Parser/SqlFileProcessor.cs @@ -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); } diff --git a/SQLLinter/Infrastructure/Reporters/FileReporter.cs b/SQLLinter/Infrastructure/Reporters/FileReporter.cs deleted file mode 100644 index f32f642..0000000 --- a/SQLLinter/Infrastructure/Reporters/FileReporter.cs +++ /dev/null @@ -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); - } -} diff --git a/SQLLinter/Infrastructure/Reporters/HTMLReporter.cs b/SQLLinter/Infrastructure/Reporters/HTMLReporter.cs index 91a75ab..e69de29 100644 --- a/SQLLinter/Infrastructure/Reporters/HTMLReporter.cs +++ b/SQLLinter/Infrastructure/Reporters/HTMLReporter.cs @@ -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 "

Нет нарушений

"; - } - - var groupedByFile = violations - .GroupBy(v => v.FileName) - .OrderBy(g => g.Key); - - var sb = new StringBuilder(); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine("Отчёт по SQL‑проверкам"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - //sb.AppendLine("

Отчёт по SQL‑проверкам

"); - - int fileIndex = 0; - foreach (var fileGroup in groupedByFile) - { - string divId = $"file_{fileIndex}"; - sb.AppendLine($"
"); - sb.AppendLine($"

Файл: {fileGroup.Key}

"); - - 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($"
"); - sb.AppendLine($"

{severityGroup.Key}

"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - - int rowIndex = 1; - foreach (var v in severityGroup - .OrderBy(x => x.Line) - .ThenBy(x => x.Column)) - { - sb.AppendLine($""); - rowIndex++; - } - - sb.AppendLine(""); - sb.AppendLine("
#СтрокаКолонкаПравилоОписание
{rowIndex}{v.Line}{v.Column}{v.RuleName}{v.Text}
"); - sb.AppendLine("
"); - } - - sb.AppendLine("
"); - fileIndex++; - } - - // Табы снизу - sb.AppendLine("
"); - fileIndex = 0; - foreach (var fileGroup in groupedByFile) - { - sb.AppendLine($"
{fileGroup.Key}
"); - fileIndex++; - } - sb.AppendLine("
"); - - // JS для переключения - sb.AppendLine(""); - - sb.AppendLine(""); - sb.AppendLine(""); - - return sb.ToString(); - } -} diff --git a/SQLLinter/Infrastructure/Reporters/HtmlReportFormatter.cs b/SQLLinter/Infrastructure/Reporters/HtmlReportFormatter.cs new file mode 100644 index 0000000..4525ed4 --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/HtmlReportFormatter.cs @@ -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 violations) + { + if (violations.Count == 0) + { + return "

Нет нарушений

"; + } + + 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($"
"); + sb.AppendLine($"

Файл: {fileGroup.Key}

"); + + 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($"
"); + sb.AppendLine($"

{severityGroup.Key}

"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + int rowIndex = 1; + foreach (var v in severityGroup + .OrderBy(x => x.Line) + .ThenBy(x => x.Column)) + { + sb.AppendLine($""); + rowIndex++; + } + + sb.AppendLine(""); + sb.AppendLine("
#СтрокаКолонкаПравилоОписание
{rowIndex}{v.Line}{v.Column}{v.RuleName}{v.Text}
"); + sb.AppendLine("
"); + } + + sb.AppendLine("
"); + fileIndex++; + } + + // Табы снизу + sb.AppendLine("
"); + fileIndex = 0; + foreach (var fileGroup in groupedByFile) + { + sb.AppendLine($"
{fileGroup.Key}
"); + fileIndex++; + } + sb.AppendLine("
"); + + GenerateEndingHtml(sb); + return sb.ToString(); + } + + public string Format(List violations, BpmnDiagram diagram) + { + if (violations.Count == 0) + { + return "

Нет нарушений

"; + } + + 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($"
"); + sb.AppendLine($"

Файл: {fileGroup.Key}

"); + + 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($"
"); + sb.AppendLine($"

{severityGroup.Key}

"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + int rowIndex = 1; + foreach (var v in severityGroup + .OrderBy(x => x.Line) + .ThenBy(x => x.Column)) + { + sb.AppendLine($""); + rowIndex++; + } + + sb.AppendLine(""); + sb.AppendLine("
#СтрокаКолонкаПравилоОписание
{rowIndex}{v.Line}{v.Column}{v.RuleName}{v.Text}
"); + sb.AppendLine("
"); + } + + sb.AppendLine("
"); + fileIndex++; + } + + //mermaid диаграмма + sb.AppendLine($"
"); + sb.AppendLine($"

Диаграмма

"); + + var mermaid = MermaidRenderer.RenderHtml(diagram); + sb.AppendLine(mermaid); + sb.AppendLine("
"); + + // Табы снизу + sb.AppendLine("
"); + fileIndex = 0; + foreach (var fileGroup in groupedByFile) + { + sb.AppendLine($"
{fileGroup.Key}
"); + fileIndex++; + } + + sb.AppendLine($"
Диграмма
"); + sb.AppendLine("
"); + + GenerateEndingHtml(sb); + return sb.ToString(); + } + + private void GenerateBeginningHtml(StringBuilder sb) + { + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("Отчёт по SQL‑проверкам"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + //sb.AppendLine("

Отчёт по SQL‑проверкам

"); + } + + private void GenerateEndingHtml(StringBuilder sb) + { + + // JS для переключения + sb.AppendLine(""); + + sb.AppendLine(""); + sb.AppendLine(""); + } +} diff --git a/SQLLinter/Infrastructure/Reporters/IReportFormatter.cs b/SQLLinter/Infrastructure/Reporters/IReportFormatter.cs new file mode 100644 index 0000000..3b7e631 --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/IReportFormatter.cs @@ -0,0 +1,10 @@ +using SQLLinter.Common; +using SQLLinter.Infrastructure.Diagram; + +namespace SQLLinter.Infrastructure.Reporters; + +public interface IReportFormatter +{ + string Format(List violations); + string Format(List violations, BpmnDiagram diagram); +} diff --git a/SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs b/SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs index adcd108..e69de29 100644 --- a/SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs +++ b/SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs @@ -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(); - - } -} \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Reporters/MarkdownReportFormatter.cs b/SQLLinter/Infrastructure/Reporters/MarkdownReportFormatter.cs new file mode 100644 index 0000000..dab9d59 --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/MarkdownReportFormatter.cs @@ -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 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 violations, BpmnDiagram diagram) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/SQLLinter/Linter.cs b/SQLLinter/Linter.cs index 0d5cf9f..99b0773 100644 --- a/SQLLinter/Linter.cs +++ b/SQLLinter/Linter.cs @@ -1,6 +1,7 @@ using SQLLinter.Common; using SQLLinter.Common.Helpers; using SQLLinter.Core.Interfaces; +using SQLLinter.Infrastructure.Interfaces; using SQLLinter.Infrastructure.Parser; using SQLLinter.Infrastructure.Plugins; @@ -15,20 +16,25 @@ public class Linter private ISqlFileProcessor _fileProcessor; public Linter(IConfig config, IReporter reporter) + : this(config + , reporter + , new FragmentBuilder(reporter, config.CompatibilityLevel) + , new SqlStreamReaderBuilder() + ) + { } + + public Linter(IConfig config, IReporter reporter, IFragmentBuilder fragmentBuilder, ISqlStreamReaderBuilder sqlStreamReaderBuilder) { this._config = config; this._reporter = reporter; - this._pluginHandler = new PluginHandler(reporter, config); - _reporter.Report($"Загрузка SQL Linter..."); - var fragmentBuilder = new FragmentBuilder(reporter, _config.CompatibilityLevel); _pluginHandler = new PluginHandler(_reporter, _config); - var ruleVisitor = new SqlRuleVisitor(_pluginHandler, fragmentBuilder, _reporter); + var ruleVisitor = new SqlRuleVisitor(_pluginHandler, fragmentBuilder, _reporter, sqlStreamReaderBuilder); - _fileProcessor = new SqlFileProcessor(ruleVisitor, _pluginHandler, reporter); + _fileProcessor = new SqlFileProcessor(ruleVisitor, _pluginHandler); _reporter.Report($"SQL Linter загружен..."); }