diff --git a/SQLLint.slnx b/SQLLint.slnx new file mode 100644 index 0000000..0fa50ea --- /dev/null +++ b/SQLLint.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/SQLLinter.CLI/Program.cs b/SQLLinter.CLI/Program.cs new file mode 100644 index 0000000..eef9297 --- /dev/null +++ b/SQLLinter.CLI/Program.cs @@ -0,0 +1,58 @@ +using SQLLinter.Infrastructure.Configuration; +using SQLLinter.Infrastructure.Reporters; + +namespace SQLLinter.CLI +{ + internal class Program + { + static void Main(string[] args) + { + var rep = new MarkdownFileReporter(); + var con = new Config() + { + CompatibilityLevel = 170, + Plugins = [], + Rules = new() + { + ["CaseSensitiveVariables"] = Common.RuleViolationSeverity.Critical, + ["ConditionalBeginEnd"] = Common.RuleViolationSeverity.Critical, + ["CountStar"] = Common.RuleViolationSeverity.Critical, + ["CrossDatabaseTransaction"] = Common.RuleViolationSeverity.Critical, + ["DataCompression"] = Common.RuleViolationSeverity.Critical, + ["DataTypeLength"] = Common.RuleViolationSeverity.Critical, + ["DeleteWhere"] = Common.RuleViolationSeverity.Critical, + ["DisallowCursors"] = Common.RuleViolationSeverity.Critical, + ["DuplicateEmptyLine"] = Common.RuleViolationSeverity.Off, + ["DuplicateGo"] = Common.RuleViolationSeverity.Critical, + ["FullText"] = Common.RuleViolationSeverity.Critical, + ["InformationSchema"] = Common.RuleViolationSeverity.Critical, + ["KeywordCapitalization"] = Common.RuleViolationSeverity.Critical, + ["LinkedServer"] = Common.RuleViolationSeverity.Critical, + ["MultiTableAlias"] = Common.RuleViolationSeverity.Critical, + ["NamedConstraint"] = Common.RuleViolationSeverity.Critical, + ["NonSargable"] = Common.RuleViolationSeverity.Critical, + ["ObjectProperty"] = Common.RuleViolationSeverity.Critical, + ["PrintStatement"] = Common.RuleViolationSeverity.Critical, + ["SchemaQualify"] = Common.RuleViolationSeverity.Critical, + ["SelectStar"] = Common.RuleViolationSeverity.Critical, + ["SemicolonTermination"] = Common.RuleViolationSeverity.Off, + ["UnicodeString"] = Common.RuleViolationSeverity.Critical, + ["UpdateWhere"] = Common.RuleViolationSeverity.Critical, + ["UpperLower"] = Common.RuleViolationSeverity.Critical, + ["SetVariable"] = Common.RuleViolationSeverity.Critical, + } + }; + + var linter = new Linter(con, rep); + + using (StreamReader reader = new StreamReader(@"C:\Users\frost\Desktop\DISTR-2599\test.sql")) + { + linter.Run("test.sql", reader.BaseStream); + } + + //linter.Run(@"C:\Users\frost\Desktop\DISTR-2599\test.sql"); + + rep.SaveReport(@"C:\Users\frost\Desktop\DISTR-2599\test.md"); + } + } +} diff --git a/SQLLinter.CLI/SQLLinter.CLI.csproj b/SQLLinter.CLI/SQLLinter.CLI.csproj new file mode 100644 index 0000000..fe27f77 --- /dev/null +++ b/SQLLinter.CLI/SQLLinter.CLI.csproj @@ -0,0 +1,29 @@ + + + + Exe + net8.0 + enable + enable + true + 1.0.0 + FrigaT + FrigaT + SQLLinter.CLI + cli клиент для проверки MS SQL кода + Copyright © 2025 FrigaT + https://git.frigat.duckdns.org/FrigaT/SQLLint + git + https://git.frigat.duckdns.org/FrigaT/SQLLint + MIT + + + + + + + + + + + diff --git a/SQLLinter/Common/BaseRuleVisitor.cs b/SQLLinter/Common/BaseRuleVisitor.cs new file mode 100644 index 0000000..36d7ab3 --- /dev/null +++ b/SQLLinter/Common/BaseRuleVisitor.cs @@ -0,0 +1,72 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace SQLLinter.Common; + +public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule +{ + protected readonly List _violations = new(); + + public int DynamicSqlStartColumn { get; set; } + public int DynamicSqlStartLine { get; set; } + public virtual string Name { get => GetDefaultRuleName(GetType().Name); } + public abstract string Text { get; } + public virtual RuleViolationSeverity Severity { get; set; } = RuleViolationSeverity.Info; + + protected string GetDefaultRuleName(string className) => + className.Substring(className.Length - 4).ToLower() == "rule" ? className.Substring(0, className.Length - 4) : className; + + + protected virtual int GetLineNumber(TSqlFragment node) => node.StartLine + GetDynamicSqlLineOffset(); + + protected virtual int GetLineNumber(TSqlParserToken node) => node.Line + GetDynamicSqlLineOffset(); + + private int GetDynamicSqlLineOffset() => + DynamicSqlStartLine > 0 + ? DynamicSqlStartLine - 1 + : 0; + + protected virtual int GetColumnNumber(TSqlFragment node) => node.StartColumn + GetDynamicSqlColumnOffset(node); + + protected virtual int GetColumnNumber(TSqlParserToken node) => node.Column + GetDynamicSqlColumnOffset(node); + + protected virtual int GetDynamicSqlColumnOffset(TSqlFragment node) => GetDynamicSqlColumnOffset(node.StartLine); + + protected virtual int GetDynamicSqlColumnOffset(TSqlParserToken node) => GetDynamicSqlColumnOffset(node.Line); + + private int GetDynamicSqlColumnOffset(int line) => + DynamicSqlStartLine > 0 && line == 1 + ? DynamicSqlStartColumn + : 0; + + public virtual void FixViolation( + List fileLines, IRuleViolation ruleViolation, FileLineActions actions) + { + } + + public virtual IEnumerable Analyze(TSqlFragment fragment) + { + _violations.Clear(); + fragment.Accept(this); + return _violations; + } + + protected void AddViolation(Violation violation) + { + _violations.Add(violation); + } + + protected void AddViolation(string RuleName, string Message, int Line, int Column) + { + _violations.Add(new(RuleName, Message, Line, Column)); + } + + protected void AddViolation(TSqlFragment node, params string[] param) + { + AddViolation(Name, this.GetText(param), GetLineNumber(node), GetColumnNumber(node)); + } + + protected string GetText(params string[] param) + { + return string.Format(this.Text, param); + } +} diff --git a/SQLLinter/Common/FileHelpers.cs b/SQLLinter/Common/FileHelpers.cs new file mode 100644 index 0000000..67e7a93 --- /dev/null +++ b/SQLLinter/Common/FileHelpers.cs @@ -0,0 +1,53 @@ +namespace SQLLinter.Common.Helpers; + +public static class FileHelpers +{ + public static List FindFilesWithMask(List paths) + { + return paths.SelectMany(path => + { + var fullPath = Path.GetFullPath(path); + var directory = Path.GetDirectoryName(fullPath); + if (directory == null) return Enumerable.Empty(); + + string pattern = Path.GetFileName(fullPath); + + // Если маска есть в директории + if (directory.Contains("*") || directory.Contains("?")) + { + var root = Path.GetPathRoot(directory); + if (root == null) return Enumerable.Empty(); + + string relative = directory.Substring(root.Length); + + return ExpandDirectories(root, relative) + .SelectMany(dir => Directory.EnumerateFiles(dir, pattern)); + } + else + { + return Directory.Exists(directory) + ? Directory.EnumerateFiles(directory, pattern) + : Enumerable.Empty(); + } + }).ToList(); + } + + public static IEnumerable ExpandDirectories(string root, string relative) + { + string[] parts = relative.Split(Path.DirectorySeparatorChar); + + IEnumerable dirs = new[] { root }; + + foreach (var part in parts) + { + dirs = part.Contains("*") || part.Contains("?") + ? dirs.SelectMany(d => Directory.Exists(d) + ? Directory.EnumerateDirectories(d, part) + : Enumerable.Empty()) + : dirs.Select(d => Path.Combine(d, part)); + } + + return dirs.Where(Directory.Exists); + } + +} diff --git a/SQLLinter/Common/FileLineActions.cs b/SQLLinter/Common/FileLineActions.cs new file mode 100644 index 0000000..07a2b96 --- /dev/null +++ b/SQLLinter/Common/FileLineActions.cs @@ -0,0 +1,98 @@ +namespace SQLLinter.Common; + +public class FileLineActions +{ + private readonly List FileLines; + + private readonly List RuleViolations; + + public FileLineActions(List ruleViolations, List fileLines) + { + RuleViolations = ruleViolations; + FileLines = fileLines; + } + + public void Insert(int index, string line) + { + InsertRange(index, new string[1] { line }); + } + + public void InsertInLine(int lineIndex, int charIndex, string content) + { + string text = FileLines[lineIndex]; + text = text.Insert(charIndex, content); + FileLines[lineIndex] = text; + foreach (IRuleViolation item in RuleViolations.Where((IRuleViolation x) => x.Line == lineIndex + 1 && x.Column > charIndex)) + { + item.Column += content.Length; + } + } + + public void InsertRange(int index, IList lines) + { + FileLines.InsertRange(index, lines); + foreach (IRuleViolation item in RuleViolations.Where((IRuleViolation x) => x.Line > index)) + { + item.Line += lines.Count; + } + } + + public void RemoveAll(Func where) + { + for (int num = FileLines.Count - 1; num >= 0; num--) + { + if (where(FileLines[num])) + { + RemoveAt(num); + } + } + } + + public void RemoveAt(int index) + { + RemoveRange(index, 1); + } + + public void RemoveInLine(int lineIndex, int charIndex, int length) + { + string text = FileLines[lineIndex]; + text = text.Remove(charIndex, length); + FileLines[lineIndex] = text; + foreach (IRuleViolation item in RuleViolations.Where((IRuleViolation x) => x.Column == lineIndex + 1 && x.Column > charIndex)) + { + item.Column -= length; + } + } + + public void RemoveRange(int index, int count) + { + FileLines.RemoveRange(index, count); + foreach (IRuleViolation item in RuleViolations.Where((IRuleViolation x) => x.Line > index)) + { + item.Line -= count; + } + } + + public void RepaceInlineAt(int lineIndex, int charIndex, string content, int? replaceLength = null) + { + string text = FileLines[lineIndex]; + text = text.Remove(charIndex, replaceLength ?? content.Length); + text = text.Insert(charIndex, content); + FileLines[lineIndex] = text; + if (!replaceLength.HasValue || replaceLength == content.Length) + { + return; + } + + int? num = content.Length - replaceLength; + foreach (IRuleViolation item in RuleViolations.Where((IRuleViolation x) => x.Line == lineIndex + 1 && x.Column > charIndex + content.Length)) + { + item.Column += num.Value; + } + } + + public void UpdateLine(int lineIndex, string content) + { + FileLines[lineIndex] = content; + } +} \ No newline at end of file diff --git a/SQLLinter/Common/FixHelpers.cs b/SQLLinter/Common/FixHelpers.cs new file mode 100644 index 0000000..de50576 --- /dev/null +++ b/SQLLinter/Common/FixHelpers.cs @@ -0,0 +1,94 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using System.Text.RegularExpressions; + +namespace SQLLinter.Common.Helpers; + +public static class FixHelpers +{ + public class FindViolatingNodeVisitor : TSqlFragmentVisitor where T : TSqlFragment + { + private readonly Func Where; + + public List Nodes = new List(); + + public FindViolatingNodeVisitor(Func where = null) + { + Where = where; + } + + public override void Visit(TSqlFragment node) + { + if (node is T val && (Where == null || Where(val))) + { + Nodes.Add(val); + } + + base.Visit(node); + } + } + + public static (TReturn, TFind) FindViolatingNode(List fileLines, IRuleViolation ruleViolation, Func getFragment) where TFind : TSqlFragment where TReturn : TSqlFragment + { + TFind val = FindNodes(fileLines).FirstOrDefault(delegate (TFind x) + { + TReturn val2 = getFragment(x); + return val2?.StartLine == ruleViolation.Line && val2?.StartColumn == ruleViolation.Column; + }); + return (getFragment(val), val); + } + + public static List FindNodes(List fileLines, Func where = null) where T : TSqlFragment + { + using StringReader input = new StringReader(string.Join("\n", fileLines)); + IList errors; + TSqlFragment tSqlFragment = new TSql150Parser(initialQuotedIdentifiers: true, SqlEngineType.All).Parse(input, out errors); + if (errors != null && errors.Any()) + { + throw new Exception("Parsing failed. " + string.Join(". ", errors.Select((ParseError x) => x.Message))); + } + + FindViolatingNodeVisitor findViolatingNodeVisitor = new FindViolatingNodeVisitor(where); + tSqlFragment.Accept(findViolatingNodeVisitor); + return findViolatingNodeVisitor.Nodes; + } + + public static List FindNodes(TSqlFragment statement, Func where = null) where T : TSqlFragment + { + FindViolatingNodeVisitor findViolatingNodeVisitor = new FindViolatingNodeVisitor(where); + statement.Accept(findViolatingNodeVisitor); + return findViolatingNodeVisitor.Nodes; + } + + public static T FindViolatingNode(List fileLines, IRuleViolation ruleViolation) where T : TSqlFragment + { + return FindViolatingNode(fileLines, ruleViolation, (T x) => x).Item1; + } + + public static string GetIndent(List fileLines, IRuleViolation ruleViolation) + { + return GetIndent(fileLines[ruleViolation.Line - 1]); + } + + public static string GetIndent(List fileLines, TSqlStatement statement) + { + return GetIndent(fileLines[statement.StartLine - 1]); + } + + public static string GetString(TSqlFragment fragment) + { + return string.Join(string.Empty, from x in fragment.ScriptTokenStream.Where((TSqlParserToken x, int i) => i >= fragment.FirstTokenIndex && i <= fragment.LastTokenIndex) + select x.Text); + } + + private static string GetIndent(string ifLine) + { + Match match = new Regex("^\\s+").Match(ifLine); + string result = string.Empty; + if (match.Success) + { + result = match.Value; + } + + return result; + } +} diff --git a/SQLLinter/Common/IBaseReporter.cs b/SQLLinter/Common/IBaseReporter.cs new file mode 100644 index 0000000..3e7be3d --- /dev/null +++ b/SQLLinter/Common/IBaseReporter.cs @@ -0,0 +1,6 @@ +namespace SQLLinter.Common; + +public interface IBaseReporter +{ + void Report(string message); +} \ No newline at end of file diff --git a/SQLLinter/Common/IReporter.cs b/SQLLinter/Common/IReporter.cs new file mode 100644 index 0000000..5f2bd94 --- /dev/null +++ b/SQLLinter/Common/IReporter.cs @@ -0,0 +1,8 @@ +namespace SQLLinter.Common; + +public interface IReporter : IBaseReporter +{ + void ReportViolation(IRuleViolation violation); + + void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string violationText); +} \ No newline at end of file diff --git a/SQLLinter/Common/IRule.cs b/SQLLinter/Common/IRule.cs new file mode 100644 index 0000000..df60ff0 --- /dev/null +++ b/SQLLinter/Common/IRule.cs @@ -0,0 +1,17 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace SQLLinter.Common; + +public interface IRule +{ + string Name { get; } + string Text { get; } + + RuleViolationSeverity Severity { get; set; } + int DynamicSqlStartColumn { get; set; } + + int DynamicSqlStartLine { get; set; } + + IEnumerable Analyze(TSqlFragment fragment); + +} \ No newline at end of file diff --git a/SQLLinter/Common/IRuleException.cs b/SQLLinter/Common/IRuleException.cs new file mode 100644 index 0000000..0d8f236 --- /dev/null +++ b/SQLLinter/Common/IRuleException.cs @@ -0,0 +1,10 @@ +namespace SQLLinter.Common; + +public interface IRuleException +{ + int StartLine { get; } + + int EndLine { get; } + + string RuleName { get; } +} diff --git a/SQLLinter/Common/IRuleViolation.cs b/SQLLinter/Common/IRuleViolation.cs new file mode 100644 index 0000000..41b5fd1 --- /dev/null +++ b/SQLLinter/Common/IRuleViolation.cs @@ -0,0 +1,16 @@ +namespace SQLLinter.Common; + +public interface IRuleViolation +{ + string FileName { get; } + + int Column { get; set; } + + int Line { get; set; } + + string RuleName { get; } + + RuleViolationSeverity Severity { get; } + + string Text { get; } +} \ No newline at end of file diff --git a/SQLLinter/Common/RuleViolationSeverity.cs b/SQLLinter/Common/RuleViolationSeverity.cs new file mode 100644 index 0000000..79b363c --- /dev/null +++ b/SQLLinter/Common/RuleViolationSeverity.cs @@ -0,0 +1,9 @@ +namespace SQLLinter.Common; + +public enum RuleViolationSeverity +{ + Off, + Info, + Warning, + Critical, +} diff --git a/SQLLinter/Common/SQLHelpers.cs b/SQLLinter/Common/SQLHelpers.cs new file mode 100644 index 0000000..465a687 --- /dev/null +++ b/SQLLinter/Common/SQLHelpers.cs @@ -0,0 +1,59 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using System.Text; + +namespace SQLLinter.Common.Helpers; + +public static class SQLHelpers +{ + + /// + /// Проверяет, что строка может быть закодирована в указанной SQL кодировке (collation). + /// + /// Строка для проверки + /// Имя кодировки, например "windows-1251" или "iso-8859-1" + /// true, если строка полностью совместима + public static bool IsValidForEncoding(string input, string sqlEncodingName) + { + if (string.IsNullOrEmpty(input)) + return true; + + Encoding enc; + try + { + // Включаем поддержку старых кодировок (ANSI, OEM) + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + enc = Encoding.GetEncoding(sqlEncodingName, + new EncoderReplacementFallback("?"), + new DecoderReplacementFallback("?")); + } + catch (ArgumentException) + { + throw new ArgumentException($"Неизвестная кодировка: {sqlEncodingName}"); + } + + return IsValidForEncoding(input, enc); + } + + public static bool IsValidForEncoding(string input, Encoding enc) + { + if (string.IsNullOrEmpty(input)) + return true; + + // Пробуем закодировать и декодировать обратно + byte[] bytes = enc.GetBytes(input); + string roundtrip = enc.GetString(bytes); + + // Если после кодирования/декодирования строка совпала - значит все символы поддерживаются + return roundtrip == input; + } + + public static string ObjectGetFullName(SchemaObjectName name) => ObjectGetFullName(name.Identifiers); + + + public static string ObjectGetFullName(IList identifiers) + { + return string.Join(".", identifiers.Select(i => "[" + i.Value + "]")); + } + +} \ No newline at end of file diff --git a/SQLLinter/Common/Violation.cs b/SQLLinter/Common/Violation.cs new file mode 100644 index 0000000..a635232 --- /dev/null +++ b/SQLLinter/Common/Violation.cs @@ -0,0 +1,4 @@ +namespace SQLLinter.Common +{ + public record Violation(string RuleName, string Message, int Line, int Column); +} diff --git a/SQLLinter/Core/Constants.cs b/SQLLinter/Core/Constants.cs new file mode 100644 index 0000000..8be9aeb --- /dev/null +++ b/SQLLinter/Core/Constants.cs @@ -0,0 +1,310 @@ +namespace SQLLinter.Core; + +public static class Constants +{ + public static readonly string[] _TSqlKeywords = + { + "ADD", + "ALL", + "ALTER", + "AND", + "ANY", + "AS", + "ASC", + "AUTHORIZATION", + "BACKUP", + "BEGIN", + "BETWEEN", + "BREAK", + "BROWSE", + "BULK", + "BY", + "CASCADE", + "CASE", + "CHECK", + "CHECKPOINT", + "CLOSE", + "CLUSTERED", + "COALESCE", + "COLLATE", + "COLUMN", + "COMMIT", + "COMPUTE", + "CONSTRAINT", + "CONTAINS", + "CONTAINSTABLE", + "CONTINUE", + "CONVERT", + "CREATE", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "CURSOR", + "DATABASE", + "DBCC", + "DEALLOCATE", + "DECLARE", + "DEFAULT", + "DELETE", + "DENY", + "DESC", + "DISK", + "DISTINCT", + "DISTRIBUTED", + "DOUBLE", + "DROP", + "DUMP", + "ELSE", + "END", + "ERRLVL", + "ESCAPE", + "EXCEPT", + "EXEC", + "EXECUTE", + "EXISTS", + "EXIT", + "EXTERNAL", + "FETCH", + "FILE", + "FILLFACTOR", + "FOR", + "FOREIGN", + "FREETEXT", + "FREETEXTTABLE", + "FROM", + "FULL", + "FUNCTION", + "GOTO", + "GRANT", + "GROUP", + "HAVING", + "HOLDLOCK", + "IDENTITY", + "IDENTITYCOL", + "IDENTITY_INSERT", + "IF", + "IN", + "INDEX", + "INNER", + "INSERT", + "INTERSECT", + "INTO", + "IS", + "JOIN", + "KEY", + "KILL", + "LEFT", + "LIKE", + "LINENO", + "LOAD", + "MERGE", + "NATIONAL", + "NOCHECK", + "NONCLUSTERED", + "NOT", + "NULL", + "NULLIF", + "OF", + "OFF", + "OFFSETS", + "ON", + "OPEN", + "OPENDATASOURCE", + "OPENQUERY", + "OPENROWSET", + "OPENXML", + "OPTION", + "OR", + "ORDER", + "OUTER", + "OVER", + "PERCENT", + "PIVOT", + "PLAN", + "PRECISION", + "PRIMARY", + "PRINT", + "PROC", + "PROCEDURE", + "PUBLIC", + "RAISERROR", + "READ", + "READTEXT", + "RECONFIGURE", + "REFERENCES", + "REPLICATION", + "RESTORE", + "RESTRICT", + "RETURN", + "REVERT", + "REVOKE", + "RIGHT", + "ROLLBACK", + "ROWCOUNT", + "ROWGUIDCOL", + "RULE", + "SAVE", + "SCHEMA", + "SECURITYAUDIT", + "SELECT", + "SEMANTICKEYPHRASETABLE", + "SEMANTICSIMILARITYDETAILSTABLE", + "SEMANTICSIMILARITYTABLE", + "SESSION_USER", + "SET", + "SETUSER", + "SHUTDOWN", + "SOME", + "STATISTICS", + "SYSTEM_USER", + "TABLE", + "TABLESAMPLE", + "TEXTSIZE", + "THEN", + "TO", + "TOP", + "TRAN", + "TRANSACTION", + "TRIGGER", + "TRUNCATE", + "TRY_CONVERT", + "TSEQUAL", + "UNION", + "UNIQUE", + "UNPIVOT", + "UPDATE", + "UPDATETEXT", + "USE", + "USER", + "VALUES", + "VARYING", + "VIEW", + "WAITFOR", + "WHEN", + "WHERE", + "WHILE", + "WITH", + "WITHIN GROUP", + "WRITETEXT" + }; + + public static readonly string[] _TSqlDataTypes = + { + "BIGINT", + "BIT", + "DECIMAL", + "INT", + "MONEY", + "NUMERIC", + "SMALLINT", + "SMALLMONEY", + "TINYINT", + "FLOAT", + "REAL", + "DATE", + "DATETIME2", + "DATETIME", + "DATETIMEOFFSET", + "SMALLDATETIME", + "TIME", + "CHAR", + "TEXT", + "VARCHAR", + "NCHAR", + "NTEXT", + "NVARCHAR", + "BINARY", + "IMAGE", + "VARBINARY", + "CURSOR", + "ROWVERSION", + "UNIQUEIDENTIFIER", + "XML" + }; + + public static readonly HashSet TSqlKeywords = new HashSet(_TSqlKeywords); + + public static readonly HashSet TSqlDataTypes = new HashSet(_TSqlDataTypes); + + public static int TabWidth => 4; + + public static int DefaultCompatabilityLevel => 120; + + public static int MaxLineWidthForRegexEval => 300; + + public static HashSet SystemFunctions = new(StringComparer.OrdinalIgnoreCase) + { + // Метаданные + "APP_NAME", "HOST_NAME", "HOST_ID", "CONNECTIONPROPERTY", "SESSION_CONTEXT", + "CURRENT_USER", "SYSTEM_USER", "SUSER_NAME", "SUSER_SID", "USER_NAME", "USER_ID", + + // Ошибки + "@@ERROR", "ERROR_MESSAGE", "ERROR_LINE", "ERROR_NUMBER", "ERROR_SEVERITY", "ERROR_STATE", "FORMATMESSAGE", + + // Идентификаторы + "@@IDENTITY", "SCOPE_IDENTITY", "IDENT_CURRENT", "@@ROWCOUNT", "ROWCOUNT_BIG", + "@@TRANCOUNT", "XACT_STATE", "CURRENT_TRANSACTION_ID", + + // Дата/время + "GETDATE", "SYSDATETIME", "SYSUTCDATETIME", "SYSDATETIMEOFFSET", "CURRENT_TIMESTAMP", "GETUTCDATE", + "DATEADD", "DATEDIFF", "DATENAME", "DATEPART", "EOMONTH", + + // Строковые/бинарные + "ISNULL", "NULLIF", "COALESCE", "DATALENGTH", "COMPRESS", "DECOMPRESS", + "BINARY_CHECKSUM", "CHECKSUM", "PARSENAME", + + // Уникальные идентификаторы + "NEWID", "NEWSEQUENTIALID", + + // Системные переменные + "@@VERSION", "@@SERVERNAME", "@@SPID", "@@LANGUAGE", "@@MAX_CONNECTIONS", + + // Database Functions + "DB_ID", "DB_NAME", "OBJECT_ID", "OBJECT_NAME", "OBJECT_SCHEMA_NAME", + "COL_LENGTH", "COL_NAME", "FILE_ID", "FILE_NAME", "SCHEMA_ID", "SCHEMA_NAME", "TYPE_ID", "TYPE_NAME", + + // Математические + "ABS", "ACOS", "ASIN", "ATAN", "ATN2", "CEILING", "COS", "COT", "DEGREES", + "EXP", "FLOOR", "LOG", "LOG10", "PI", "POWER", "RADIANS", "RAND", "ROUND", "SIGN", "SIN", "SQRT", "SQUARE", "TAN", + + // Агрегатные + "AVG", "COUNT", "COUNT_BIG", "MIN", "MAX", "SUM", + + // Курсоры + "CURSOR_STATUS", + + // Конфигурационные и серверные + "@@OPTIONS", "SESSIONPROPERTY", "INDEXPROPERTY", "INDEX_COL", "COLLATIONPROPERTY", + "SERVERPROPERTY", "DATABASEPROPERTYEX", "OBJECTPROPERTY", "OBJECTPROPERTYEX", + + // Безопасность + "HAS_DBACCESS", "IS_MEMBER", "IS_ROLEMEMBER", "IS_SRVROLEMEMBER", "PERMISSIONS", "PWDCOMPARE", "PWDENCRYPT", + + // JSON + "JSON_VALUE", "JSON_QUERY", "JSON_MODIFY", + + // Аналитические (оконные) + "CUME_DIST", "RANK", "DENSE_RANK", "NTILE", "ROW_NUMBER", + "LEAD", "LAG", "FIRST_VALUE", "LAST_VALUE", + "PERCENT_RANK", "PERCENTILE_CONT", "PERCENTILE_DISC", + + // XML + "nodes", "value", "query", "exist", "modify", + + // CLR + "FORMAT", "TRY_CONVERT", "TRY_CAST", "TRY_PARSE", "PARSE", + + // Spatial (geometry/geography) + "STArea", "STLength", "STDistance", "STIntersects", "STBuffer", "STUnion", "STDifference", "STIntersection", + "STGeomFromText", "STGeomFromWKB", "STPointFromText", "STPointFromWKB", + "STAsText", "STAsBinary", "STEnvelope", "STCentroid", "STIsEmpty", "STIsValid", + + // Statistical + "STDEV", "STDEVP", "VAR", "VARP", "CHECKSUM_AGG", "COLUMNS_UPDATED" + }; + + +} diff --git a/SQLLinter/Core/DTO/HandlerResponseMessage.cs b/SQLLinter/Core/DTO/HandlerResponseMessage.cs new file mode 100644 index 0000000..c794418 --- /dev/null +++ b/SQLLinter/Core/DTO/HandlerResponseMessage.cs @@ -0,0 +1,20 @@ +namespace SQLLinter.Core.DTO; + +public class HandlerResponseMessage +{ + public HandlerResponseMessage(bool success, bool shouldLint) + : this(success, shouldLint, false) + { + } + + public HandlerResponseMessage(bool success, bool shouldLint, bool shouldFix) + { + Success = success; + ShouldLint = shouldLint; + ShouldFix = shouldFix; + } + + public bool Success { get; } + public bool ShouldLint { get; } + public bool ShouldFix { get; } +} \ No newline at end of file diff --git a/SQLLinter/Core/Interfaces/IAssemblyWrapper.cs b/SQLLinter/Core/Interfaces/IAssemblyWrapper.cs new file mode 100644 index 0000000..bfa8531 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IAssemblyWrapper.cs @@ -0,0 +1,10 @@ +using System.Reflection; + +namespace SQLLinter.Core.Interfaces; + +public interface IAssemblyWrapper +{ + Assembly LoadFrom(string path); + + Type[] GetExportedTypes(Assembly assembly); +} diff --git a/SQLLinter/Core/Interfaces/IConfig.cs b/SQLLinter/Core/Interfaces/IConfig.cs new file mode 100644 index 0000000..54f3eba --- /dev/null +++ b/SQLLinter/Core/Interfaces/IConfig.cs @@ -0,0 +1,24 @@ +using SQLLinter.Common; + +namespace SQLLinter.Core.Interfaces +{ + public interface IConfig + { + /// + /// Уровень совместимости SQL Server + /// + int CompatibilityLevel { get; set; } + + /// + /// Включенные правила. + /// Key - Название правила. + /// Value - Уровень предупреждения. + /// + Dictionary Rules { get; set; } + + /// + /// Список сторонних плагинов. + /// + List Plugins { get; set; } + } +} diff --git a/SQLLinter/Core/Interfaces/IExtendedRuleException.cs b/SQLLinter/Core/Interfaces/IExtendedRuleException.cs new file mode 100644 index 0000000..2bd7562 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IExtendedRuleException.cs @@ -0,0 +1,8 @@ +using SQLLinter.Common; + +namespace SQLLinter.Core.Interfaces; + +public interface IExtendedRuleException : IRuleException +{ + void SetEndLine(int endLine); +} diff --git a/SQLLinter/Core/Interfaces/IFileSystemWrapper.cs b/SQLLinter/Core/Interfaces/IFileSystemWrapper.cs new file mode 100644 index 0000000..d411289 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IFileSystemWrapper.cs @@ -0,0 +1,10 @@ +namespace SQLLinter.Core.Interfaces; + +public interface IFileSystemWrapper +{ + bool FileExists(string path); + + bool PathIsValidForLint(string path); + + string CombinePath(params string[] paths); +} diff --git a/SQLLinter/Core/Interfaces/IFileversionWrapper.cs b/SQLLinter/Core/Interfaces/IFileversionWrapper.cs new file mode 100644 index 0000000..1cc51c9 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IFileversionWrapper.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace SQLLinter.Core.Interfaces; + +public interface IFileversionWrapper +{ + string GetVersion(Assembly assembly); +} diff --git a/SQLLinter/Core/Interfaces/IGlobPatternMatcher.cs b/SQLLinter/Core/Interfaces/IGlobPatternMatcher.cs new file mode 100644 index 0000000..771e790 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IGlobPatternMatcher.cs @@ -0,0 +1,6 @@ +namespace SQLLinter.Core.Interfaces; + +public interface IGlobPatternMatcher +{ + IEnumerable GetResultsInFullPath(string path); +} diff --git a/SQLLinter/Core/Interfaces/IOverride.cs b/SQLLinter/Core/Interfaces/IOverride.cs new file mode 100644 index 0000000..daca711 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IOverride.cs @@ -0,0 +1,4 @@ +namespace SQLLinter.Core.Interfaces; + +public interface IOverride +{ } diff --git a/SQLLinter/Core/Interfaces/IPluginHandler.cs b/SQLLinter/Core/Interfaces/IPluginHandler.cs new file mode 100644 index 0000000..d233daa --- /dev/null +++ b/SQLLinter/Core/Interfaces/IPluginHandler.cs @@ -0,0 +1,9 @@ +using SQLLinter.Common; + +namespace SQLLinter.Core.Interfaces; + +public interface IPluginHandler +{ + IList Rules { get; } + IDictionary RuleWithNames { get; } +} diff --git a/SQLLinter/Core/Interfaces/IRequest.cs b/SQLLinter/Core/Interfaces/IRequest.cs new file mode 100644 index 0000000..8721101 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IRequest.cs @@ -0,0 +1,19 @@ +namespace SQLLinter.Core.Interfaces.Config.Contracts; + +public interface IRequestHandler where TRequest : IRequest +{ + TResponse Handle(TRequest request); +} + +public interface IRequestHandler where TRequest : IRequest +{ + void Handle(TRequest message); +} + +public interface IRequest +{ +} + +public interface IRequest +{ +} diff --git a/SQLLinter/Core/Interfaces/IRuleExceptionFinder.cs b/SQLLinter/Core/Interfaces/IRuleExceptionFinder.cs new file mode 100644 index 0000000..504d194 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IRuleExceptionFinder.cs @@ -0,0 +1,6 @@ +namespace SQLLinter.Core.Interfaces; + +public interface IRuleExceptionFinder +{ + IEnumerable GetIgnoredRuleList(Stream fileStream); +} diff --git a/SQLLinter/Core/Interfaces/IRuleVisitor.cs b/SQLLinter/Core/Interfaces/IRuleVisitor.cs new file mode 100644 index 0000000..9474f05 --- /dev/null +++ b/SQLLinter/Core/Interfaces/IRuleVisitor.cs @@ -0,0 +1,8 @@ +using SQLLinter.Common; + +namespace SQLLinter.Core.Interfaces; + +public interface IRuleVisitor +{ + void VisitRules(string path, IEnumerable igoredRules, Stream sqlFileStream); +} diff --git a/SQLLinter/Core/Interfaces/ISqlFileProcessor.cs b/SQLLinter/Core/Interfaces/ISqlFileProcessor.cs new file mode 100644 index 0000000..3adbdf2 --- /dev/null +++ b/SQLLinter/Core/Interfaces/ISqlFileProcessor.cs @@ -0,0 +1,11 @@ + +namespace SQLLinter.Core.Interfaces; + +public interface ISqlFileProcessor +{ + int FileCount { get; } + + void ProcessList(List filePaths); + void ProcessList(Dictionary files); + void ProcessPath(string path); +} diff --git a/SQLLinter/Infrastructure/Configuration/Config.cs b/SQLLinter/Infrastructure/Configuration/Config.cs new file mode 100644 index 0000000..ddcf1ac --- /dev/null +++ b/SQLLinter/Infrastructure/Configuration/Config.cs @@ -0,0 +1,12 @@ +using SQLLinter.Common; +using SQLLinter.Core.Interfaces; + +namespace SQLLinter.Infrastructure.Configuration +{ + public class Config : IConfig + { + public int CompatibilityLevel { get; set; } + public Dictionary Rules { get; set; } = new(); + public List Plugins { get; set; } = new(); + } +} diff --git a/SQLLinter/Infrastructure/Configuration/EnvironmentWrapper.cs b/SQLLinter/Infrastructure/Configuration/EnvironmentWrapper.cs new file mode 100644 index 0000000..1b7494c --- /dev/null +++ b/SQLLinter/Infrastructure/Configuration/EnvironmentWrapper.cs @@ -0,0 +1,10 @@ +namespace SQLLinter.Infrastructure.Configuration +{ + public class EnvironmentWrapper + { + public string GetEnvironmentVariable(string name) + { + return Environment.GetEnvironmentVariable(name); + } + } +} diff --git a/SQLLinter/Infrastructure/Configuration/Overrides/OverrideCompatabilityLevel.cs b/SQLLinter/Infrastructure/Configuration/Overrides/OverrideCompatabilityLevel.cs new file mode 100644 index 0000000..34c6399 --- /dev/null +++ b/SQLLinter/Infrastructure/Configuration/Overrides/OverrideCompatabilityLevel.cs @@ -0,0 +1,22 @@ +using SQLLinter.Core; +using SQLLinter.Core.Interfaces; + +namespace SQLLinter.Infrastructure.Configuration.Overrides +{ + public class OverrideCompatabilityLevel : IOverride + { + public OverrideCompatabilityLevel(string value) + { + if (int.TryParse(value, out var parsedCompatabilityLevel)) + { + CompatabilityLevel = parsedCompatabilityLevel; + } + else + { + CompatabilityLevel = Constants.DefaultCompatabilityLevel; + } + } + + public int CompatabilityLevel { get; } + } +} diff --git a/SQLLinter/Infrastructure/Configuration/Overrides/OverrideFinder.cs b/SQLLinter/Infrastructure/Configuration/Overrides/OverrideFinder.cs new file mode 100644 index 0000000..07a5dd2 --- /dev/null +++ b/SQLLinter/Infrastructure/Configuration/Overrides/OverrideFinder.cs @@ -0,0 +1,46 @@ +using SQLLinter.Core; +using SQLLinter.Core.Interfaces; +using System.Text.RegularExpressions; + +namespace SQLLinter.Infrastructure.Configuration.Overrides; + +public class OverrideFinder +{ + private static Regex _OverrideRegex = new Regex(@".*?sqllinter-override ?(.* += +.*)+.*", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public IEnumerable GetOverrideList(Stream fileStream) + { + var overrideList = new List(); + TextReader reader = new StreamReader(fileStream); + + string line; + while ((line = reader.ReadLine()) != null) + { + if (line.Length > Constants.MaxLineWidthForRegexEval || !line.Contains("sqllinter-override")) + { + continue; + } + + var match = _OverrideRegex.Match(line); + if (!match.Success) + { + continue; + } + + var overrideDetails = match.Groups[1].Value.Split(',').Select(p => p.Trim()).ToList(); + foreach (var overrideDetail in overrideDetails) + { + var details = overrideDetail.Split(' ').Select(p => p.Trim()).ToList(); + if (OverrideTypeMap.List.ContainsKey(details[0])) + { + var overrideType = OverrideTypeMap.List.GetValueOrDefault(details[0]); + overrideList.Add((IOverride)Activator.CreateInstance(overrideType, details[2])); + } + } + } + + fileStream.Seek(0, SeekOrigin.Begin); + + return overrideList; + } +} diff --git a/SQLLinter/Infrastructure/Configuration/Overrides/OverrideTypeMap.cs b/SQLLinter/Infrastructure/Configuration/Overrides/OverrideTypeMap.cs new file mode 100644 index 0000000..1714f83 --- /dev/null +++ b/SQLLinter/Infrastructure/Configuration/Overrides/OverrideTypeMap.cs @@ -0,0 +1,12 @@ +namespace SQLLinter.Infrastructure.Configuration.Overrides +{ + public class OverrideTypeMap + { + public static readonly Dictionary List = new Dictionary + { + { "compatibility-level", typeof(OverrideCompatabilityLevel) }, + // Deprecate usage of misspelled "compatability-level". + { "compatability-level", typeof(OverrideCompatabilityLevel) } + }; + } +} diff --git a/SQLLinter/Infrastructure/Interfaces/IFragmentBuilder.cs b/SQLLinter/Infrastructure/Interfaces/IFragmentBuilder.cs new file mode 100644 index 0000000..7ea2e62 --- /dev/null +++ b/SQLLinter/Infrastructure/Interfaces/IFragmentBuilder.cs @@ -0,0 +1,10 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Core.Interfaces; + +namespace SQLLinter.Infrastructure.Interfaces +{ + public interface IFragmentBuilder + { + TSqlFragment? GetFragment(string sqlPath, TextReader txtRdr, out IList errors, IEnumerable overrides = null); + } +} diff --git a/SQLLinter/Infrastructure/Interfaces/ISqlStreamReaderBuilder.cs b/SQLLinter/Infrastructure/Interfaces/ISqlStreamReaderBuilder.cs new file mode 100644 index 0000000..d3ac596 --- /dev/null +++ b/SQLLinter/Infrastructure/Interfaces/ISqlStreamReaderBuilder.cs @@ -0,0 +1,7 @@ +namespace SQLLinter.Infrastructure.Interfaces +{ + public interface ISqlStreamReaderBuilder + { + StreamReader CreateReader(Stream sqlFileStream); + } +} diff --git a/SQLLinter/Infrastructure/Interfaces/IViolationFixer.cs b/SQLLinter/Infrastructure/Interfaces/IViolationFixer.cs new file mode 100644 index 0000000..a305e47 --- /dev/null +++ b/SQLLinter/Infrastructure/Interfaces/IViolationFixer.cs @@ -0,0 +1,7 @@ +namespace SQLLinter.Infrastructure.Interfaces +{ + public interface IViolationFixer + { + void Fix(); + } +} diff --git a/SQLLinter/Infrastructure/Parser/DynamicSQLParser.cs b/SQLLinter/Infrastructure/Parser/DynamicSQLParser.cs new file mode 100644 index 0000000..054fd15 --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/DynamicSQLParser.cs @@ -0,0 +1,152 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace SQLLinter.Infrastructure.Parser +{ + public class DynamicSQLParser : TSqlFragmentVisitor + { + private readonly Action callback; + private string executableSql = string.Empty; + private Dictionary VariableValues = new(); + + private int DynamicSQLStartingLine { get; set; } + + private int DynamicSQLStartingColumn { get; set; } + + public DynamicSQLParser(Action callback) + { + this.callback = callback; + } + + public override void Visit(TSqlBatch node) + { + var variableVisitor = new VariableVisitor(); + node.Accept(variableVisitor); + VariableValues = variableVisitor.VariableValues; + } + + public override void Visit(ExecuteStatement node) + { + DynamicSQLStartingColumn = node.ExecuteSpecification.ExecutableEntity.StartColumn; + DynamicSQLStartingLine = node.ExecuteSpecification.ExecutableEntity.StartLine; + + var visitor = new VariableVisitor(); + node.Accept(visitor); + + var executableStrings = node.ExecuteSpecification.ExecutableEntity as ExecutableStringList; + if (executableStrings?.Strings == null) + { + return; + } + + var counter = 0; + foreach (var executableString in executableStrings.Strings) + { + counter++; + if (executableString is StringLiteral literal) + { + HandleLiteral(counter, executableStrings.Strings.Count, literal); + } + else if (executableString is VariableReference variableReference) + { + HandleVariable(counter, executableStrings.Strings.Count, variableReference); + } + } + } + + private void HandleVariable(int counter, int executableCount, VariableReference variableReference) + { + if (!VariableValues.ContainsKey(variableReference.Name) || !VariableValues.TryGetValue(variableReference.Name, out var value)) + { + return; + } + + executableSql += value.Value; + + if (counter == executableCount) + { + callback(executableSql, value.StartLine, value.StartColumn); + } + } + + private void HandleLiteral(int counter, int executableCount, StringLiteral literal) + { + executableSql += literal.Value; + if (counter == executableCount) + { + callback(executableSql, DynamicSQLStartingLine, DynamicSQLStartingColumn); + } + } + } + + public class VariableVisitor : TSqlFragmentVisitor + { + public Dictionary VariableValues { get; } = new(); + + public override void Visit(SelectSetVariable node) + { + HandleExpression(node.Variable.Name, node.Expression); + } + + public override void Visit(SetVariableStatement node) + { + HandleExpression(node.Variable.Name, node.Expression); + } + + private void HandleExpression(string name, ScalarExpression expression) + { + switch (expression) + { + case StringLiteral strLiteral: + VariableValues[name] = new VariableRef(strLiteral); + break; + case IntegerLiteral intLiteral: + VariableValues[name] = new VariableRef(intLiteral); + break; + case BinaryExpression binaryExpression: + HandleBinaryExpression(name, binaryExpression); + break; + } + } + + private void HandleBinaryExpression(string name, BinaryExpression expression) + { + if (expression.BinaryExpressionType != BinaryExpressionType.Add) + { + return; + } + + if (expression.FirstExpression is StringLiteral first + && expression.SecondExpression is StringLiteral second) + { + VariableValues[name] = new VariableRef(first) + { + Value = first.Value + second.Value + }; + } + } + + public struct VariableRef + { + public VariableRef(StringLiteral stringLiteral) + : this((Literal)stringLiteral) + { + } + + public VariableRef(IntegerLiteral integerLiteral) + : this((Literal)integerLiteral) + { + } + + private VariableRef(Literal literal) + { + StartColumn = literal.StartColumn; + StartLine = literal.StartLine; + Value = literal.Value; + } + + public int StartColumn { get; set; } + public int StartLine { get; set; } + public string Value { get; set; } + } + } +} diff --git a/SQLLinter/Infrastructure/Parser/FileSystemWrapper.cs b/SQLLinter/Infrastructure/Parser/FileSystemWrapper.cs new file mode 100644 index 0000000..192a01f --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/FileSystemWrapper.cs @@ -0,0 +1,39 @@ +using SQLLinter.Core.Interfaces; + +namespace SQLLinter.Infrastructure.Parser +{ + public class FileSystemWrapper : IFileSystemWrapper + { + + public bool FileExists(string path) + { + path = RemoveQuotes(path); + return File.Exists(path); + } + + public bool PathIsValidForLint(string path) + { + path = RemoveQuotes(path); + if (!File.Exists(path)) + { + return Directory.Exists(path) || PathContainsWildCard(path); + } + return true; + } + + private static bool PathContainsWildCard(string filePath) + { + return filePath.Contains("*") || filePath.Contains("?"); + } + + private string RemoveQuotes(string path) + { + return path.Replace("\"", string.Empty); + } + + public string CombinePath(params string[] paths) + { + return Path.Combine(paths); + } + } +} diff --git a/SQLLinter/Infrastructure/Parser/FragmentBuilder.cs b/SQLLinter/Infrastructure/Parser/FragmentBuilder.cs new file mode 100644 index 0000000..1e0536c --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/FragmentBuilder.cs @@ -0,0 +1,84 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Core; +using SQLLinter.Core.Interfaces; +using SQLLinter.Infrastructure.Configuration.Overrides; +using SQLLinter.Infrastructure.Interfaces; + +namespace SQLLinter.Infrastructure.Parser; + +public class FragmentBuilder : IFragmentBuilder +{ + private readonly TSqlParser parser; + private readonly IReporter _reporter; + + public FragmentBuilder(IReporter reporter) : this(reporter, Constants.DefaultCompatabilityLevel) + { + } + + public FragmentBuilder(IReporter reporter, int compatabilityLevel) + { + parser = GetSqlParser(compatabilityLevel); + _reporter = reporter; + } + + public TSqlFragment? GetFragment(string path, TextReader txtRdr, out IList errors, IEnumerable overrides = null) + { + TSqlFragment fragment; + + OverrideCompatabilityLevel? compatibilityLevel = null; + if (overrides != null) + { + foreach (var lintingOverride in overrides) + { + if (lintingOverride is OverrideCompatabilityLevel overrideCompatability) + { + compatibilityLevel = overrideCompatability; + } + } + } + + TSqlParser curParser; + + if (compatibilityLevel != null) + { + curParser = GetSqlParser(compatibilityLevel.CompatabilityLevel); + } + else + { + curParser = parser; + } + + fragment = curParser.Parse(txtRdr, out errors); //TODO: Возвращать эти ошибки + + if (fragment == null) + { + foreach (var err in errors) + { + _reporter.ReportViolation(path, err.Line, err.Column, RuleViolationSeverity.Critical, "parse", err.Message); + } + } + + return fragment?.FirstTokenIndex != -1 ? fragment : null; + } + + private static TSqlParser GetSqlParser(int compatabilityLevel) + { + TSqlParser parser = compatabilityLevel switch + { + 80 => new TSql80Parser(true), + 90 => new TSql90Parser(true), + 100 => new TSql100Parser(true), + 110 => new TSql110Parser(true), + 120 => new TSql120Parser(true), + 130 => new TSql130Parser(true), + 140 => new TSql140Parser(true), + 150 => new TSql150Parser(true), + 160 => new TSql160Parser(true), + 170 => new TSql170Parser(true), + _ => new TSql120Parser(true), + }; + + return parser; + } +} diff --git a/SQLLinter/Infrastructure/Parser/ParsingUtility.cs b/SQLLinter/Infrastructure/Parser/ParsingUtility.cs new file mode 100644 index 0000000..3e7fb5e --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/ParsingUtility.cs @@ -0,0 +1,23 @@ +using System.Text; + +namespace SQLLinter.Infrastructure.Parser +{ + public static class ParsingUtility + { + public static TextReader CreateTextReaderFromString(string str) + { + var bytes = Encoding.UTF8.GetBytes(str); + return new StreamReader(new MemoryStream(bytes)); + } + + public static Stream GenerateStreamFromString(string s) + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(s); + writer.Flush(); + stream.Position = 0; + return stream; + } + } +} diff --git a/SQLLinter/Infrastructure/Parser/RuleVisitorFriendlyNameTypeMap.cs b/SQLLinter/Infrastructure/Parser/RuleVisitorFriendlyNameTypeMap.cs new file mode 100644 index 0000000..ed32ca7 --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/RuleVisitorFriendlyNameTypeMap.cs @@ -0,0 +1,28 @@ +using SQLLinter.Common; +using System.Reflection; + +namespace SQLLinter.Infrastructure.Parser +{ + public static class RuleVisitorFriendlyNameTypeMap + { + public static List DefaultRuleTypes + { + get + { + Assembly assembly = Assembly.GetExecutingAssembly(); + + return GetRuleTypes(assembly); + } + } + + public static List GetRuleTypes(Assembly assembly) + { + List sqlRuleTypes = assembly + .GetTypes() + .Where(t => typeof(IRule).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract) // исключаем абстрактные + .ToList(); + + return sqlRuleTypes; + } + } +} diff --git a/SQLLinter/Infrastructure/Parser/SqlFileProcessor.cs b/SQLLinter/Infrastructure/Parser/SqlFileProcessor.cs new file mode 100644 index 0000000..0c4333c --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/SqlFileProcessor.cs @@ -0,0 +1,109 @@ +using SQLLinter.Common; +using SQLLinter.Core.Interfaces; +using SQLLinter.Infrastructure.Rules.RuleExceptions; + +namespace SQLLinter.Infrastructure.Parser; + +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) + { + this.ruleVisitor = ruleVisitor; + this.pluginHandler = pluginHandler; + this.reporter = reporter; + ruleExceptionFinder = new RuleExceptionFinder(pluginHandler.RuleWithNames); + } + + private int _fileCount; + public int FileCount + { + get + { + return _fileCount; + } + } + + 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 bool IsWholeFileIgnored(string filePath, IEnumerable ignoredRules) + { + var ignoredRulesEnum = ignoredRules.ToArray(); + if (!ignoredRulesEnum.Any()) + { + return false; + } + + var lineOneRuleIgnores = ignoredRulesEnum.OfType().Where(x => 1 == x.StartLine).ToArray(); + if (!lineOneRuleIgnores.Any()) + { + return false; + } + + var lineCount = 0; + using (var reader = new StreamReader(GetFileContents(filePath))) + { + while (reader.ReadLine() != null) + { + lineCount++; + } + } + + return lineOneRuleIgnores.Any(x => x.EndLine == lineCount); + } + + private void HandleProcessing(string filePath, Stream fileStream) + { + var ignoredRules = ruleExceptionFinder.GetIgnoredRuleList(fileStream).ToList(); + if (IsWholeFileIgnored(filePath, ignoredRules)) + { + return; + } + ProcessRules(fileStream, ignoredRules, filePath); + } + + private void ProcessRules(Stream fileStream, IEnumerable ignoredRules, string filePath) + { + ruleVisitor.VisitRules(filePath, ignoredRules, fileStream); + } + + private Stream GetFileContents(string filePath) + { + return File.OpenRead(filePath); + } +} diff --git a/SQLLinter/Infrastructure/Parser/SqlRuleVisitor.cs b/SQLLinter/Infrastructure/Parser/SqlRuleVisitor.cs new file mode 100644 index 0000000..eb04ec4 --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/SqlRuleVisitor.cs @@ -0,0 +1,123 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Core.Interfaces; +using SQLLinter.Infrastructure.Configuration.Overrides; +using SQLLinter.Infrastructure.Interfaces; +using SQLLinter.Infrastructure.Rules.RuleExceptions; +using SQLLinter.Infrastructure.Rules.RuleViolations; +using System.Data; + +namespace SQLLinter.Infrastructure.Parser; + +public class SqlRuleVisitor : IRuleVisitor +{ + private readonly IFragmentBuilder _fragmentBuilder; + private readonly IReporter _reporter; + private readonly ISqlStreamReaderBuilder _sqlStreamReaderBuilder; + private readonly IPluginHandler _pluginHandler; + + private readonly OverrideFinder _overrideFinder = new OverrideFinder(); + + public SqlRuleVisitor(IPluginHandler pluginHandler, IFragmentBuilder fragmentBuilder, IReporter reporter) + : this(pluginHandler, fragmentBuilder, reporter, new SqlStreamReaderBuilder()) { } + + public SqlRuleVisitor(IPluginHandler pluginHandler, IFragmentBuilder fragmentBuilder, IReporter reporter, ISqlStreamReaderBuilder sqlStreamReaderBuilder) + { + this._fragmentBuilder = fragmentBuilder; + this._reporter = reporter; + this._pluginHandler = pluginHandler; + this._sqlStreamReaderBuilder = sqlStreamReaderBuilder; + } + + public void VisitRules(string sqlPath, IEnumerable ignoredRules, Stream sqlFileStream) + { + var overrides = _overrideFinder.GetOverrideList(sqlFileStream); + var overrideArray = overrides as IOverride[] ?? overrides.ToArray(); + + var sqlFragment = _fragmentBuilder.GetFragment(sqlPath, GetSqlTextReader(sqlFileStream), out var errors, overrideArray); + + if (sqlFragment == null) return; + + var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray(); + if (errors.Any()) + { + HandleParserErrors(sqlPath, errors, ruleExceptions); + } + + var rules = _pluginHandler.Rules; + foreach (var rule in rules) + { + VisitFragment(sqlFragment, rule, overrideArray, sqlPath); + } + } + + private void VisitFragment(TSqlFragment sqlFragment, IRule rule, IEnumerable overrides, string filePath) + { + var violations = rule.Analyze(sqlFragment).ToList(); + + + if (!VisitorIsBlackListedForDynamicSql(rule)) + { + var dynamicSqlVisitor = new DynamicSQLParser(DynamicSqlCallback); + sqlFragment?.Accept(dynamicSqlVisitor); + } + + void DynamicSqlCallback(string dynamicSQL, int DynamicSqlStartLine, int DynamicSqlStartColumn) + { + rule.DynamicSqlStartLine = DynamicSqlStartLine; + rule.DynamicSqlStartColumn = DynamicSqlStartColumn; + + var dynamicSqlStream = ParsingUtility.GenerateStreamFromString(dynamicSQL); + var dynamicFragment = _fragmentBuilder.GetFragment(filePath, GetSqlTextReader(dynamicSqlStream), out var errors, overrides); + + if (dynamicFragment != null) + { + violations.AddRange(rule.Analyze(dynamicFragment)); + } + } + + violations.ForEach(t => _reporter.ReportViolation(filePath, t.Line, t.Column, rule.Severity, t.RuleName, t.Message)); + } + + private static bool VisitorIsBlackListedForDynamicSql(IRule visitor) + { + return new List + { + "SetAnsiNullsRule", + "SetNoCountRule", + "SetQuotedIdentifierRule", + "SetTransactionIsolationLevelRule", + "UnicodeStringRule" + }.Any(x => visitor.GetType().ToString().Contains(x)); + } + + private StreamReader GetSqlTextReader(Stream sqlFileStream) + { + return _sqlStreamReaderBuilder.CreateReader(sqlFileStream); + } + + private void HandleParserErrors(string sqlPath, IEnumerable errors, IEnumerable ignoredRules) + { + var updatedExitCode = false; + var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray(); + + foreach (var error in errors) + { + var globalRulesOnLine = ruleExceptions.OfType().Where( + x => error.Line >= x.StartLine + && error.Line <= x.EndLine); + + if (!globalRulesOnLine.Any()) + { + _reporter.ReportViolation(new RuleViolation(sqlPath, "invalid-syntax", error.Message, error.Line, error.Column, RuleViolationSeverity.Critical)); + if (updatedExitCode) + { + continue; + } + + updatedExitCode = true; + Environment.ExitCode = 1; + } + } + } +} diff --git a/SQLLinter/Infrastructure/Parser/SqlStreamReaderBuilder.cs b/SQLLinter/Infrastructure/Parser/SqlStreamReaderBuilder.cs new file mode 100644 index 0000000..5719890 --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/SqlStreamReaderBuilder.cs @@ -0,0 +1,44 @@ +using SQLLinter.Infrastructure.Interfaces; +using System.Text; +using System.Text.RegularExpressions; + +namespace SQLLinter.Infrastructure.Parser; + +public class SqlStreamReaderBuilder : ISqlStreamReaderBuilder +{ + private static readonly Regex _placeholderRegex = new Regex(@"\$\((?[^)]+)\)", RegexOptions.Compiled); + + public StreamReader CreateReader(Stream sqlFileStream) + { + var sqlText = new StreamReader(sqlFileStream); + sqlFileStream.Seek(0, SeekOrigin.Begin); + var sql = ReplaceSqlPlaceholders(sqlText.ReadToEnd()); + return new StreamReader(new MemoryStream(sqlText.CurrentEncoding.GetBytes(sql))); + } + + private string ReplaceSqlPlaceholders(string sql) + { + var matches = _placeholderRegex.Matches(sql); + + if (matches.Count == 0) + { + return sql; + } + + var newSql = new StringBuilder(); + var i = 0; + + foreach (Match match in matches) + { + var placeholder = match.Groups["placeholder"].Value; + var replacement = match.Value; + newSql.Append(sql.Substring(i, match.Index - i)); + newSql.Append(replacement); + i = match.Index + match.Length; + } + + newSql.Append(sql.Substring(i)); + + return newSql.ToString(); + } +} diff --git a/SQLLinter/Infrastructure/Parser/ViolationFixer.cs b/SQLLinter/Infrastructure/Parser/ViolationFixer.cs new file mode 100644 index 0000000..110838e --- /dev/null +++ b/SQLLinter/Infrastructure/Parser/ViolationFixer.cs @@ -0,0 +1,50 @@ +using SQLLinter.Common; +using SQLLinter.Infrastructure.Interfaces; + +namespace SQLLinter.Infrastructure.Parser; + +public class ViolationFixer : IViolationFixer +{ + private readonly Dictionary Rules; + private readonly IList Violations; + + public ViolationFixer( + Dictionary rules, + IList violations) + { + Rules = rules; + Violations = violations; + } + + public void Fix() + { + var files = Violations.GroupBy(x => x.FileName); + + foreach (var file in files) + { + var fileViolations = file + .OrderByDescending(x => x.Line) + .ThenByDescending(x => x.Column) + .ToList(); + + var fileLines = File.ReadAllLines(file.Key).ToList(); + var fileLineActions = new Common.FileLineActions(fileViolations, fileLines); + + foreach (var violation in fileViolations) + { + if (Rules.ContainsKey(violation.RuleName)) + { + if (violation.Line == 1 && violation.Column > fileLines[violation.Line - 1].Length + 1) + { + continue; + } + + var lines = new List(fileLines); + //Rules[violation.RuleName].FixViolation(lines, violation, fileLineActions); + } + } + + File.WriteAllLines(file.Key, fileLines); + } + } +} diff --git a/SQLLinter/Infrastructure/Plugins/AssemblyWrapper.cs b/SQLLinter/Infrastructure/Plugins/AssemblyWrapper.cs new file mode 100644 index 0000000..2386e52 --- /dev/null +++ b/SQLLinter/Infrastructure/Plugins/AssemblyWrapper.cs @@ -0,0 +1,18 @@ +using SQLLinter.Core.Interfaces; +using System.Reflection; + +namespace SQLLinter.Infrastructure.Plugins +{ + public class AssemblyWrapper : IAssemblyWrapper + { + public Assembly LoadFrom(string path) + { + return Assembly.LoadFrom(path); + } + + public Type[] GetExportedTypes(Assembly assembly) + { + return assembly.GetExportedTypes(); + } + } +} diff --git a/SQLLinter/Infrastructure/Plugins/PluginHandler.cs b/SQLLinter/Infrastructure/Plugins/PluginHandler.cs new file mode 100644 index 0000000..46b3ade --- /dev/null +++ b/SQLLinter/Infrastructure/Plugins/PluginHandler.cs @@ -0,0 +1,130 @@ +using SQLLinter.Common; +using SQLLinter.Common.Helpers; +using SQLLinter.Core.Interfaces; +using SQLLinter.Infrastructure.Parser; + +namespace SQLLinter.Infrastructure.Plugins +{ + public class PluginHandler : IPluginHandler + { + private readonly IConfig _config; + private readonly IAssemblyWrapper _assemblyWrapper; + private readonly IReporter _reporter; + private readonly IFileversionWrapper _versionWrapper; + + private List _ruleTypes = new(); + private Dictionary _rules = new(); + + public IList Rules => _rules.Values.ToList(); + + public IDictionary RuleWithNames => _rules; + + public PluginHandler(IReporter reporter, IConfig config) + : this(reporter, new AssemblyWrapper(), new VersionInfoWrapper(), config) { } + + public PluginHandler( + IReporter reporter, + IAssemblyWrapper assemblyWrapper, + IFileversionWrapper versionWrapper, + IConfig config + ) + { + this._reporter = reporter; + this._assemblyWrapper = assemblyWrapper; + this._versionWrapper = versionWrapper; + this._config = config; + + LoadDefaultRules(); + LoadPlugins(); + } + + private void ActivatePlugin(Type type) + { + var instance = (IRule?)Activator.CreateInstance(type); + + if (instance == null) + { + _reporter.Report($"Ошибка создания экземпляра правила '{type.FullName}'"); + return; + } + + var ruleName = instance.Name; + + if (_config.Rules.TryGetValue(ruleName, out var severity)) + { + instance.Severity = severity; + } + + if (instance.Severity == RuleViolationSeverity.Off) + { + _reporter.Report($"Правило '{ruleName}' выключено"); + return; + } + + if (_rules.ContainsKey(ruleName)) + { + _reporter.Report($"Ошибка добавления правила '{type.FullName}': правило '{ruleName}' уже создано"); + return; + } + + _rules.Add(ruleName, instance); + + _reporter.Report($"Правило '{ruleName}' ({instance.Severity}) добавлено с типом '{type.FullName}'"); + } + + private void LoadDefaultRules() + { + //Загрузка дефолтных правил + var defaultRuleTypes = RuleVisitorFriendlyNameTypeMap.DefaultRuleTypes; + defaultRuleTypes.ForEach(AddRuleType); + } + + private void AddRuleType(Type ruleType) + { + if (_ruleTypes.Contains(ruleType)) + { + _reporter.Report($"Уже загружено правило '{ruleType.FullName}'"); + return; + } + + _ruleTypes.Add(ruleType); + _reporter.Report($"Добавлено правило '{ruleType.FullName}'"); + + ActivatePlugin(ruleType); + } + + private void LoadPlugins() + { + var paths = FileHelpers.FindFilesWithMask(_config.Plugins); + + //Загрузка плагинов + foreach (var path in paths) + { + if (!File.Exists(path)) + { + _reporter.Report($"Ошибка загрузки плагина '{path}': Файл не найден."); + } + else if (Path.GetExtension(path)?.ToLower() != "dll") + { + _reporter.Report($"Ошибка загрузки плагина '{path}': Неподдерживаемый формат файла. Ожидается файл с расширением .dll."); + } + else + { + LoadPlugin(path); + } + } + } + + private void LoadPlugin(string path) + { + var dll = _assemblyWrapper.LoadFrom(path); + var version = _versionWrapper.GetVersion(dll); + + foreach (var type in _assemblyWrapper.GetExportedTypes(dll)) + { + var rules = RuleVisitorFriendlyNameTypeMap.GetRuleTypes(dll); + rules.ForEach(AddRuleType); + } + } + } +} diff --git a/SQLLinter/Infrastructure/Plugins/VersionInfoWrapper.cs b/SQLLinter/Infrastructure/Plugins/VersionInfoWrapper.cs new file mode 100644 index 0000000..17cd6e5 --- /dev/null +++ b/SQLLinter/Infrastructure/Plugins/VersionInfoWrapper.cs @@ -0,0 +1,14 @@ +using SQLLinter.Core.Interfaces; +using System.Diagnostics; +using System.Reflection; + +namespace SQLLinter.Infrastructure.Plugins +{ + public class VersionInfoWrapper : IFileversionWrapper + { + public string GetVersion(Assembly assembly) + { + return FileVersionInfo.GetVersionInfo(assembly.Location).FileVersion; + } + } +} diff --git a/SQLLinter/Infrastructure/Reporters/FileReporter.cs b/SQLLinter/Infrastructure/Reporters/FileReporter.cs new file mode 100644 index 0000000..f32f642 --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/FileReporter.cs @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..91a75ab --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/HTMLReporter.cs @@ -0,0 +1,145 @@ +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/MarkdownFileReporter.cs b/SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs new file mode 100644 index 0000000..adcd108 --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/MarkdownFileReporter.cs @@ -0,0 +1,53 @@ +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/Reporter.cs b/SQLLinter/Infrastructure/Reporters/Reporter.cs new file mode 100644 index 0000000..04f4b39 --- /dev/null +++ b/SQLLinter/Infrastructure/Reporters/Reporter.cs @@ -0,0 +1,41 @@ +using SQLLinter.Common; +using SQLLinter.Infrastructure.Rules.RuleViolations; +using System.Collections.Concurrent; + +namespace SQLLinter.Infrastructure.Reporters; + +public class Reporter : IReporter +{ + private readonly List _log = new(); + + public int? FixedCount { get; set; } + private readonly ConcurrentBag ruleViolations = new(); + + public List Violations => ruleViolations.ToList(); + + public virtual void Report(string message) + { + _log.Add(message); + } + + public List GetLog() => _log; + + public void ClearViolations() + { + Report("Очистка ошибок"); + + ruleViolations.Clear(); + } + + public void ReportViolation(IRuleViolation violation) + { + ruleViolations.Add(violation); + + Report(violation.ToString()); + } + + public void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string violationText) + { + ReportViolation(new RuleViolation(fileName, ruleName, violationText, line, column, severity)); + } +} diff --git a/SQLLinter/Infrastructure/Rules/AliasStyleConsistencyRule.cs b/SQLLinter/Infrastructure/Rules/AliasStyleConsistencyRule.cs new file mode 100644 index 0000000..74db812 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/AliasStyleConsistencyRule.cs @@ -0,0 +1,35 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class AliasStyleConsistencyRule : BaseRuleVisitor +{ + public override string Text => "Алиасы должны использовать единый стиль (AS): {0}"; + + public override void Visit(NamedTableReference node) + { + if (node.Alias != null && node.Alias.QuoteType == QuoteType.NotQuoted) + { + // ScriptDom не хранит явно "AS", но можно проверять TokenStream + var aliasToken = node.Alias; + var tokens = node.ScriptTokenStream; + if (tokens != null) + { + int idx = node.FirstTokenIndex; + bool hasAs = false; + for (int i = idx; i <= node.LastTokenIndex; i++) + { + if (tokens[i].Text.Equals("AS", System.StringComparison.OrdinalIgnoreCase)) + { + hasAs = true; + break; + } + } + if (!hasAs) + AddViolation(node, $"[{node.Alias.Value}]"); + } + } + base.Visit(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/AlterInsteadOfCreateOrAlterRule.cs b/SQLLinter/Infrastructure/Rules/AlterInsteadOfCreateOrAlterRule.cs new file mode 100644 index 0000000..b855ab1 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/AlterInsteadOfCreateOrAlterRule.cs @@ -0,0 +1,25 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class AlterInsteadOfCreateOrAlterRule : BaseRuleVisitor +{ + public override string Text => "Запрещено использовать ALTER, допускается только CREATE OR ALTER: {0}."; + + public override void Visit(AlterProcedureStatement node) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + } + + public override void Visit(AlterFunctionStatement node) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.Name)); + } + + public override void Visit(AlterTriggerStatement node) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.Name)); + } +} diff --git a/SQLLinter/Infrastructure/Rules/AlterProcedureInDboRule.cs b/SQLLinter/Infrastructure/Rules/AlterProcedureInDboRule.cs new file mode 100644 index 0000000..711fff8 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/AlterProcedureInDboRule.cs @@ -0,0 +1,18 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class AlterProcedureInDboRule : BaseRuleVisitor +{ + public override string Text => "Запрещено изменение процедур в схеме dbo: {0}"; + + public override void Visit(AlterProcedureStatement node) + { + if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/BetweenRule.cs b/SQLLinter/Infrastructure/Rules/BetweenRule.cs new file mode 100644 index 0000000..5ce7807 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/BetweenRule.cs @@ -0,0 +1,26 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class BetweenRule : BaseRuleVisitor +{ + public override string Text => "Избегать BETWEEN, использовать >= и <"; + + public override void Visit(TSqlScript node) + { + base.Visit(node); + + var tokens = node.ScriptTokenStream; + if (tokens == null) return; + + foreach (var t in tokens) + { + if (t.TokenType == TSqlTokenType.Between) + { + AddViolation(Name, Text, t.Line, t.Column); + } + } + } + +} diff --git a/SQLLinter/Infrastructure/Rules/CaseSensitiveVariablesRule.cs b/SQLLinter/Infrastructure/Rules/CaseSensitiveVariablesRule.cs new file mode 100644 index 0000000..7d0a853 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/CaseSensitiveVariablesRule.cs @@ -0,0 +1,46 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +/// +/// Имена переменных будут использовать общий регистр +/// +public class CaseSensitiveVariablesRule : BaseRuleVisitor, IRule +{ + private readonly List variableNames; + + public CaseSensitiveVariablesRule() + { + this.variableNames = new List(); + } + + public override string Text => "Ожидается, что имена переменных будут использовать один регистр: {0} (DECLARE {1})"; + + public override void Visit(TSqlBatch node) + { + variableNames.Clear(); + } + + public override void Visit(DeclareVariableStatement node) + { + foreach (var declaration in node.Declarations) + { + variableNames.Add(declaration.VariableName.Value); + } + } + + public override void Visit(VariableReference node) + { + var variableName = node.Name; + var caseInsensitiveMatch = variableNames.Where(v => v.ToUpper() == variableName.ToUpper()); + + if (!caseInsensitiveMatch.Any()) return; + + var declareName = caseInsensitiveMatch.First(); + + if (declareName == variableName) return; + + AddViolation(node, variableName, declareName); + } +} diff --git a/SQLLinter/Infrastructure/Rules/ColumnNullabilityRule.cs b/SQLLinter/Infrastructure/Rules/ColumnNullabilityRule.cs new file mode 100644 index 0000000..879b04b --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/ColumnNullabilityRule.cs @@ -0,0 +1,80 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class ColumnNullabilityRule : BaseRuleVisitor +{ + public override string Text => "При объявлении таблицы необходимо указывать для столбцов NULL/NOT NULL: таблица {0} - столбец [{1}]"; + + private string? _currentTable; + + public override void Visit(CreateTableStatement node) + { + _currentTable = SQLHelpers.ObjectGetFullName(node.SchemaObjectName); + + + foreach (var element in node.Definition.ColumnDefinitions) + { + CheckColumn(element); + } + + _currentTable = null; + } + + public override void Visit(AlterTableAddTableElementStatement node) + { + _currentTable = SQLHelpers.ObjectGetFullName(node.SchemaObjectName); + + foreach (var element in node.Definition.ColumnDefinitions) + { + CheckColumn(element); + } + + _currentTable = null; + } + + public override void Visit(AlterTableAlterColumnStatement node) + { + _currentTable = SQLHelpers.ObjectGetFullName(node.SchemaObjectName); + + bool hasNullabilityConstraint = node.Options + .OfType() + .Any(); + + if (!hasNullabilityConstraint) + { + AddViolation(node, + _currentTable ?? "", + node.ColumnIdentifier?.Value ?? ""); + } + + _currentTable = null; + + } + + public override void Visit(DeclareTableVariableStatement node) + { + _currentTable = node.Body.VariableName.Value; + + foreach (var column in node.Body.Definition.ColumnDefinitions) + { + CheckColumn(column); + } + } + + private void CheckColumn(ColumnDefinition node) + { + bool hasNullabilityConstraint = node.Constraints + .OfType() + .Any(); + + if (!hasNullabilityConstraint) + { + AddViolation(node, + _currentTable ?? "", + node.ColumnIdentifier?.Value ?? ""); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/Common/ColumnNumberCalculator.cs b/SQLLinter/Infrastructure/Rules/Common/ColumnNumberCalculator.cs new file mode 100644 index 0000000..7342943 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/Common/ColumnNumberCalculator.cs @@ -0,0 +1,107 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Core; + +namespace SQLLinter.Infrastructure.Rules.Common; + +public static class ColumnNumberCalculator +{ + public static int GetNodeColumnPosition(TSqlFragment node) + { + var line = string.Empty; + var nodeStartLine = node.StartLine; + var nodeLastLine = node.ScriptTokenStream[node.LastTokenIndex].Line; + + for (var tokenIndex = 0; tokenIndex <= node.LastTokenIndex; tokenIndex++) + { + var token = node.ScriptTokenStream[tokenIndex]; + if (token.Line >= nodeStartLine && token.Line <= nodeLastLine) + { + line += token.Text; + } + } + + var positionOfNodeOnLine = line.LastIndexOf(node.ScriptTokenStream[node.FirstTokenIndex].Text, StringComparison.Ordinal); + var charactersBeforeNode = line.Substring(0, positionOfNodeOnLine); + + var offSet = 0; + if (charactersBeforeNode.IndexOf(" ", StringComparison.Ordinal) != -1) + { + offSet = 1; + } + + var tabCount = CountTabs(charactersBeforeNode); + var totalTabLength = tabCount * Constants.TabWidth; + + var nodePosition = totalTabLength + (charactersBeforeNode.Length - tabCount) + offSet; + return nodePosition; + } + + /// + /// Returns -1 if can't be found + /// + /// + /// + /// + public static int GetIndex(string line, int column) + { + var index = 0; + while (index != column) + { + if (line.Length <= index) + { + return -1; + } + + if (line[index] == '\t') + { + column -= (Constants.TabWidth - 1); + } + index++; + } + return column - 1; + } + + private static int CountTabs(string charactersBeforeNode) + { + int tabCount = 0; + foreach (var c in charactersBeforeNode) + { + if (c == '\t') + { + tabCount++; + } + } + + return tabCount; + } + + // count all tabs on a line up to the last token index + public static int CountTabsBeforeToken(int lastTokenLine, int lastTokenIndex, IEnumerable tokens) + { + var tabCount = 0; + var sqlParserTokens = tokens as TSqlParserToken[] ?? tokens.ToArray(); + + for (var tokenIndex = 0; tokenIndex < lastTokenIndex; tokenIndex++) + { + var token = sqlParserTokens[tokenIndex]; + if (token.Line != lastTokenLine || string.IsNullOrEmpty(token.Text)) + { + continue; + } + + tabCount += CountTabs(token.Text); + } + + return tabCount; + } + + public static int GetColumnNumberBeforeToken(int tabsOnLine, TSqlParserToken token) + { + return token.Column + ((tabsOnLine * Constants.TabWidth) - tabsOnLine); + } + + public static int GetColumnNumberAfterToken(int tabsOnLine, TSqlParserToken token) + { + return token.Column + token.Text.Length + ((tabsOnLine * Constants.TabWidth) - tabsOnLine); + } +} diff --git a/SQLLinter/Infrastructure/Rules/ConditionalBeginEndRule.cs b/SQLLinter/Infrastructure/Rules/ConditionalBeginEndRule.cs new file mode 100644 index 0000000..d704eec --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/ConditionalBeginEndRule.cs @@ -0,0 +1,69 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; +using System.Text.RegularExpressions; + +namespace SQLLinter.Infrastructure.Rules; + +/// +/// Ожидается наличие BEGIN - END в блоке IF +/// +public class ConditionalBeginEndRule : BaseRuleVisitor, IRule +{ + private readonly Regex IsWhiteSpaceOrSemiColon = new Regex(@"\s|;", RegexOptions.Compiled); + + + public override string Text => "Ожидается наличие BEGIN - END в блоке IF"; + + public override void Visit(IfStatement node) + { + if (node.ThenStatement is not BeginEndBlockStatement) + { + AddViolation(Name, Text, GetLineNumber(node), GetColumnNumber(node)); + } + + if (node.ElseStatement != null && node.ElseStatement is not BeginEndBlockStatement && node.ElseStatement is not IfStatement) + { + AddViolation(Name, Text, GetLineNumber(node.ElseStatement), GetColumnNumber(node.ElseStatement)); + } + } + + public override void FixViolation(List fileLines, IRuleViolation ruleViolation, FileLineActions actions) + { + var ifNode = FixHelpers.FindViolatingNode(fileLines, ruleViolation); + TSqlStatement statement; + + if (ifNode == null) + { + (statement, ifNode) = FindElse(fileLines, ruleViolation); + } + else + { + statement = ifNode.ThenStatement; + } + + var stream = statement.ScriptTokenStream; + var indent = FixHelpers.GetIndent(fileLines, ifNode); + var beingLine = stream[statement.FirstTokenIndex].Line - 1; + var ifNodeLastToken = stream[statement.LastTokenIndex]; + var endLine = stream[statement.LastTokenIndex].Line; + + if (statement.StartLine == ifNodeLastToken.Line) + { + var index = statement.LastTokenIndex; + actions.InsertInLine(statement.StartLine - 1, stream[index].Column, " END"); + actions.InsertInLine(statement.StartLine - 1, statement.StartColumn - 1, "BEGIN "); + } + else + { + actions.Insert(endLine, $"{indent}END"); + actions.Insert(beingLine, $"{indent}BEGIN"); + } + + static (TSqlStatement, IfStatement) FindElse(List fileLines, IRuleViolation ruleViolation) + { + return FixHelpers.FindViolatingNode( + fileLines, ruleViolation, x => x.ElseStatement); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/CountStarRule.cs b/SQLLinter/Infrastructure/Rules/CountStarRule.cs new file mode 100644 index 0000000..534e02a --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/CountStarRule.cs @@ -0,0 +1,61 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class CountStarRule : BaseRuleVisitor, IRule +{ + public override string Text => "COUNT(*) запрещен. Используйте COUNT(1) или COUNT()"; + + public override void Visit(FunctionCall node) + { + var functionName = node.FunctionName?.Value; + if (functionName == null || !functionName.ToUpper().Equals("COUNT")) + { + return; + } + foreach (ScalarExpression param in node.Parameters) + { + var paramVisitor = new ParameterVisitor(); + param.Accept(paramVisitor); + if (paramVisitor.IsWildcard) + { + AddViolation(node); + } + } + } + + public override void FixViolation(List fileLines, IRuleViolation ruleViolation, FileLineActions actions) + { + var node = FixHelpers.FindViolatingNode(fileLines, ruleViolation); + + foreach (ScalarExpression param in node.Parameters) + { + var paramVisitor = new ParameterVisitor(); + param.Accept(paramVisitor); + if (paramVisitor.IsWildcard) + { + var whileCard = paramVisitor.Expression; + actions.RepaceInlineAt(whileCard.StartLine - 1, whileCard.StartColumn - 1, "1"); + } + } + } + + private class ParameterVisitor : TSqlFragmentVisitor + { + public bool IsWildcard { get; private set; } + public ColumnReferenceExpression Expression { get; private set; } + + public ParameterVisitor() + { + IsWildcard = false; + } + + public override void Visit(ColumnReferenceExpression node) + { + IsWildcard = node.ColumnType.Equals(ColumnType.Wildcard); + Expression = node; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/CreateProcedureInDboRule.cs b/SQLLinter/Infrastructure/Rules/CreateProcedureInDboRule.cs new file mode 100644 index 0000000..06cd809 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/CreateProcedureInDboRule.cs @@ -0,0 +1,25 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class CreateProcedureInDboRule : BaseRuleVisitor +{ + public override string Text => "Запрещено создание процедур в схеме dbo: {0}"; + + public override void Visit(CreateProcedureStatement node) + { + if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + } + } + public override void Visit(CreateOrAlterProcedureStatement node) + { + if (node.ProcedureReference.Name.SchemaIdentifier?.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase) == true) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/CrossDatabaseReferenceRule.cs b/SQLLinter/Infrastructure/Rules/CrossDatabaseReferenceRule.cs new file mode 100644 index 0000000..2493271 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/CrossDatabaseReferenceRule.cs @@ -0,0 +1,19 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class CrossDatabaseReferenceRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Запрещено указывать имя базы данных в объекте. Используйте синонимы: {0}"; + + public override void Visit(NamedTableReference node) + { + if (node.SchemaObject.DatabaseIdentifier != null) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.SchemaObject)); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/CrossDatabaseTransactionRule.cs b/SQLLinter/Infrastructure/Rules/CrossDatabaseTransactionRule.cs new file mode 100644 index 0000000..45a6527 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/CrossDatabaseTransactionRule.cs @@ -0,0 +1,118 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class CrossDatabaseTransactionRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Межбазовые вставки или обновления, включенные в транзакцию, могут привести к повреждению данных."; + + public override void Visit(TSqlBatch node) + { + var childTransactionVisitor = new ChildTransactionVisitor(); + node.Accept(childTransactionVisitor); + foreach (var transaction in childTransactionVisitor.TransactionLists) + { + var childInsertUpdateQueryVisitor = new ChildInsertUpdateQueryVisitor(transaction); + node.Accept(childInsertUpdateQueryVisitor); + if (childInsertUpdateQueryVisitor.DatabasesUpdated.Count > 1) + { + AddViolation( + Name, + Text, + GetLineNumber(transaction.Begin), + GetColumnNumber(transaction.Begin)); + } + } + } + + public class TrackedTransaction + { + public BeginTransactionStatement Begin { get; set; } + + public CommitTransactionStatement Commit { get; set; } + } + + public class ChildTransactionVisitor : TSqlFragmentVisitor + { + public List TransactionLists { get; } = new List(); + + public override void Visit(BeginTransactionStatement node) + { + TransactionLists.Add(new TrackedTransaction { Begin = node }); + } + + public override void Visit(CommitTransactionStatement node) + { + var firstUncomitted = TransactionLists.LastOrDefault(x => x.Commit == null); + if (firstUncomitted != null) + { + firstUncomitted.Commit = node; + } + } + } + + public class ChildInsertUpdateQueryVisitor : TSqlFragmentVisitor + { + private readonly TrackedTransaction transaction; + + private readonly ChildDatabaseNameVisitor childDatabaseNameVisitor = new ChildDatabaseNameVisitor(); + + public ChildInsertUpdateQueryVisitor(TrackedTransaction transaction) + { + this.transaction = transaction; + } + + public HashSet DatabasesUpdated { get; } = new HashSet(); + + public override void Visit(InsertStatement node) + { + GetDatabasesUpdated(node); + } + + public override void Visit(UpdateStatement node) + { + GetDatabasesUpdated(node); + } + + private void GetDatabasesUpdated(TSqlFragment node) + { + if (IsWithinTransaction(node)) + { + node.Accept(childDatabaseNameVisitor); + DatabasesUpdated.UnionWith(childDatabaseNameVisitor.DatabasesUpdated); + } + } + + private bool IsWithinTransaction(TSqlFragment node) + { + if (node.StartLine == transaction.Begin?.StartLine && + node.StartColumn < transaction.Begin?.StartColumn) + { + return false; + } + + if (node.StartLine == transaction.Commit?.StartLine && + node.StartColumn > transaction.Commit?.StartColumn) + { + return false; + } + + return node.StartLine >= transaction.Begin?.StartLine && node.StartLine <= transaction.Commit?.StartLine; + } + } + + public class ChildDatabaseNameVisitor : TSqlFragmentVisitor + { + public HashSet DatabasesUpdated { get; } = new HashSet(); + + public override void Visit(NamedTableReference node) + { + if (node.SchemaObject.DatabaseIdentifier != null) + { + DatabasesUpdated.Add(node.SchemaObject.DatabaseIdentifier.Value); + } + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/DataCompressionOptionRule.cs b/SQLLinter/Infrastructure/Rules/DataCompressionOptionRule.cs new file mode 100644 index 0000000..574568b --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/DataCompressionOptionRule.cs @@ -0,0 +1,38 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class DataCompressionOptionRule : BaseRuleVisitor +{ + + public override string Text => "Объявление таблицы без использования сжатия данных: {0}"; + + public override void Visit(CreateTableStatement node) + { + if (node.SchemaObjectName.BaseIdentifier.Value.StartsWith("#")) return; + + var childCompressionVisitor = new ChildCompressionVisitor(); + node.AcceptChildren(childCompressionVisitor); + + if (!childCompressionVisitor.CompressionOptionExists) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.SchemaObjectName)); + } + } + + private class ChildCompressionVisitor : TSqlFragmentVisitor + { + public bool CompressionOptionExists + { + get; + private set; + } + + public override void Visit(DataCompressionOption node) + { + CompressionOptionExists = true; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/DataTypeLengthRule.cs b/SQLLinter/Infrastructure/Rules/DataTypeLengthRule.cs new file mode 100644 index 0000000..a1feafb --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/DataTypeLengthRule.cs @@ -0,0 +1,35 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class DataTypeLengthRule : BaseRuleVisitor, IRule +{ + private readonly SqlDataTypeOption[] typesThatRequireLength = + { + SqlDataTypeOption.Char, + SqlDataTypeOption.VarChar, + SqlDataTypeOption.NVarChar, + SqlDataTypeOption.NChar, + SqlDataTypeOption.Binary, + SqlDataTypeOption.VarBinary, + SqlDataTypeOption.Decimal, + SqlDataTypeOption.Numeric, + SqlDataTypeOption.Float + }; + + public override string Text => "Длина типа данных не указана: {0}"; + + public override void Visit(SqlDataTypeReference node) + { + if (typesThatRequireLength.Any(option => Equals(option, node.SqlDataTypeOption) && node.Parameters.Count < 1)) + { + AddViolation(node, node.Name.BaseIdentifier.Value); + } + } + + protected override int GetColumnNumber(TSqlFragment node) + { + return node.FragmentLength + base.GetColumnNumber(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/DeleteWhereRule.cs b/SQLLinter/Infrastructure/Rules/DeleteWhereRule.cs new file mode 100644 index 0000000..9ddf99a --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/DeleteWhereRule.cs @@ -0,0 +1,25 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class DeleteWhereRule : BaseRuleVisitor, IRule +{ + public override string Text => "Ожидается предложение WHERE для оператора DELETE: {0}"; + + public override void Visit(DeleteSpecification node) + { + if (node.WhereClause != null) + { + return; + } + + string name = string.Join("", node.Target.ScriptTokenStream + .Skip(node.Target.FirstTokenIndex) + .Take(node.Target.LastTokenIndex - node.Target.FirstTokenIndex + 1) + .Select(t => t.Text) + ); + + AddViolation(node, name); + } +} diff --git a/SQLLinter/Infrastructure/Rules/DisallowCursorRule.cs b/SQLLinter/Infrastructure/Rules/DisallowCursorRule.cs new file mode 100644 index 0000000..7af2f25 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/DisallowCursorRule.cs @@ -0,0 +1,15 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class DisallowCursorRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Обнаружено использование оператора CURSOR"; + + public override void Visit(CursorStatement node) + { + AddViolation(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/DistinctRule.cs b/SQLLinter/Infrastructure/Rules/DistinctRule.cs new file mode 100644 index 0000000..b98d2ae --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/DistinctRule.cs @@ -0,0 +1,18 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class DistinctRule : BaseRuleVisitor +{ + public override string Text => "Избегать DISTINCT, отдавать предпочтение GROUP BY."; + + public override void Visit(SelectStatement node) + { + var query = node.QueryExpression as QuerySpecification; + if (query != null && query.SelectElements.Any() && query.UniqueRowFilter == UniqueRowFilter.Distinct) + { + AddViolation(node); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/DuplicateAliasRule.cs b/SQLLinter/Infrastructure/Rules/DuplicateAliasRule.cs new file mode 100644 index 0000000..ae2e09b --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/DuplicateAliasRule.cs @@ -0,0 +1,209 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using System.Collections.Generic; + +namespace SQLLinter.Infrastructure.Rules; + +public class DuplicateAliasRule : BaseRuleVisitor +{ + public override string Text => "Алиасы таблиц должны быть уникальными: {0}"; + + private readonly Dictionary> _activeAliases = new(); + private int _dmlDepth = 0; + private bool _insideApply = false; + + private void EnterDmlScope(bool inherit) + { + _dmlDepth++; + if (_dmlDepth == 1 || !inherit) + { + _activeAliases[_dmlDepth] = new HashSet(StringComparer.OrdinalIgnoreCase); + } + else + { + // дочерний скоуп видит алиасы родителя (для глобальной уникальности в рамках одного DML) + var parent = _activeAliases[_dmlDepth - 1]; + _activeAliases[_dmlDepth] = new HashSet(parent, StringComparer.OrdinalIgnoreCase); + } + } + + private void ExitDmlScope() + { + if (_activeAliases.ContainsKey(_dmlDepth)) + _activeAliases.Remove(_dmlDepth); + _dmlDepth--; + } + + private void RegisterAlias(string alias, TSqlFragment node) + { + if (_dmlDepth == 0) return; // вне DML не проверяем + var set = _activeAliases[_dmlDepth]; + + if (set.Contains(alias)) + { + AddViolation(node, "[" + alias + "]"); + } + else + { + set.Add(alias); + } + } + + // Корневые DML-выражения + public override void ExplicitVisit(SelectStatement node) + { + EnterDmlScope(true); + base.ExplicitVisit(node); + ExitDmlScope(); + } + + public override void ExplicitVisit(InsertStatement node) + { + EnterDmlScope(true); + base.ExplicitVisit(node); + ExitDmlScope(); + } + + public override void ExplicitVisit(UpdateStatement node) + { + EnterDmlScope(true); + base.ExplicitVisit(node); + ExitDmlScope(); + } + + public override void ExplicitVisit(DeleteStatement node) + { + EnterDmlScope(true); + base.ExplicitVisit(node); + ExitDmlScope(); + } + + public override void ExplicitVisit(MergeStatement node) + { + EnterDmlScope(true); + base.ExplicitVisit(node); + ExitDmlScope(); + } + + // IF: каждая ветка - отдельный скоуп + public override void ExplicitVisit(IfStatement node) + { + EnterDmlScope(false); + node.ThenStatement.Accept(this); + ExitDmlScope(); + + if (node.ElseStatement != null) + { + EnterDmlScope(false); + node.ElseStatement.Accept(this); + ExitDmlScope(); + } + + base.ExplicitVisit(node); + } + + // UNION: каждая часть - отдельный скоуп + public override void ExplicitVisit(BinaryQueryExpression node) + { + EnterDmlScope(true); + node.FirstQueryExpression.Accept(this); + ExitDmlScope(); + + EnterDmlScope(true); + node.SecondQueryExpression.Accept(this); + ExitDmlScope(); + + //base.ExplicitVisit(node); + } + + // CTE: регистрируем имя, тело - в дочернем скоупе + public override void ExplicitVisit(CommonTableExpression node) + { + if (node.ExpressionName?.Value is { Length: > 0 } cteAlias) + RegisterAlias(cteAlias, node); + + EnterDmlScope(false); + node.QueryExpression.Accept(this); + ExitDmlScope(); + + base.ExplicitVisit(node); + } + + // Производная таблица: регистрируем её алиас; внутренняя QueryExpression обойдётся базой + public override void ExplicitVisit(QueryDerivedTable node) + { + if (node.Alias?.Value is { Length: > 0 } a) + RegisterAlias(a, node); + + + // тело подзапроса в FROM - свой локальный скоуп + EnterDmlScope(_insideApply); + node.QueryExpression.Accept(this); + ExitDmlScope(); + } + + // CROSS APPLY + public override void ExplicitVisit(UnqualifiedJoin node) + { + if (node.UnqualifiedJoinType == UnqualifiedJoinType.CrossApply || + node.UnqualifiedJoinType == UnqualifiedJoinType.OuterApply) + { + var prev = _insideApply; + _insideApply = true; + + // обходим без нового скоупа, т.к. алиасы учитываются глобально + node.FirstTableReference.Accept(this); + node.SecondTableReference.Accept(this); + + _insideApply = prev; + } + else + { + base.ExplicitVisit(node); + } + } + + // Табличные источники с алиасами + public override void ExplicitVisit(NamedTableReference node) + { + if (node.Alias?.Value is { Length: > 0 } a) + RegisterAlias(a, node); + base.ExplicitVisit(node); + } + + public override void ExplicitVisit(VariableTableReference node) + { + if (node.Alias?.Value is { Length: > 0 } a) + RegisterAlias(a, node); + base.ExplicitVisit(node); + } + + public override void ExplicitVisit(SchemaObjectFunctionTableReference node) + { + if (node.Alias?.Value is { Length: > 0 } a) + RegisterAlias(a, node); + base.ExplicitVisit(node); + } + + public override void ExplicitVisit(PivotedTableReference node) + { + if (node.Alias?.Value is { Length: > 0 } a) + RegisterAlias(a, node); + base.ExplicitVisit(node); + } + + public override void ExplicitVisit(UnpivotedTableReference node) + { + if (node.Alias?.Value is { Length: > 0 } a) + RegisterAlias(a, node); + base.ExplicitVisit(node); + } + + // Сброс состояния перед анализом скрипта + public override void ExplicitVisit(TSqlScript node) + { + _dmlDepth = 0; + _activeAliases.Clear(); + base.ExplicitVisit(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/DuplicateEmptyLineRule.cs b/SQLLinter/Infrastructure/Rules/DuplicateEmptyLineRule.cs new file mode 100644 index 0000000..3c30170 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/DuplicateEmptyLineRule.cs @@ -0,0 +1,51 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; +using System.Text.RegularExpressions; + +namespace SQLLinter.Infrastructure.Rules; + +public class DuplicateEmptyLineRule : BaseRuleVisitor, IRule +{ + private static readonly Regex EmptyLineRegex = new(@"^\s*$", RegexOptions.Compiled); + + + public override string Text => "Обнаружен дубликат новой строки"; + + public override void Visit(TSqlScript node) + { + var isEmptyLine = false; + var fileLines = FixHelpers.GetString(node) + .Split('\n') + .ToList(); + + for (var i = 0; i < fileLines.Count; i++) + { + if (EmptyLineRegex.IsMatch(fileLines[i])) + { + if (isEmptyLine) + { + AddViolation(Name, Text, i + 1, 1); + } + + isEmptyLine = true; + } + else + { + isEmptyLine = false; + } + } + } + + public override void FixViolation(List fileLines, IRuleViolation ruleViolation, FileLineActions actions) + { + if (ruleViolation.Line - 1 == fileLines.Count) + { + actions.RemoveAt(ruleViolation.Line - 2); + } + else + { + actions.RemoveAt(ruleViolation.Line - 1); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/DuplicateGoRule.cs b/SQLLinter/Infrastructure/Rules/DuplicateGoRule.cs new file mode 100644 index 0000000..c85cc3d --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/DuplicateGoRule.cs @@ -0,0 +1,39 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class DuplicateGoRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Обнаружен дублирующийся оператор GO"; + + public override void Visit(TSqlScript node) + { + TSqlParserToken lastToken = null; + TSqlParserToken currentToken; + for (var index = 0; index <= node.LastTokenIndex; index++) + { + var tokenType = node.ScriptTokenStream[index].TokenType; + + // Skip these + switch (tokenType) + { + case TSqlTokenType.MultilineComment: + case TSqlTokenType.WhiteSpace: + case TSqlTokenType.Semicolon: + continue; + } + + currentToken = node.ScriptTokenStream[index]; + + if (tokenType is TSqlTokenType.Go && + lastToken?.TokenType is TSqlTokenType.Go) + { + AddViolation(Name, Text, currentToken.Line, currentToken.Column); + } + + lastToken = currentToken; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/ExcessiveJoinsRule.cs b/SQLLinter/Infrastructure/Rules/ExcessiveJoinsRule.cs new file mode 100644 index 0000000..670248d --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/ExcessiveJoinsRule.cs @@ -0,0 +1,20 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class ExcessiveJoinsRule : BaseRuleVisitor +{ + public override string Text => "Слишком много таблиц в запросе (>" + _maxJoins + "): {0}"; + + private const int _maxJoins = 10; + + public override void Visit(QuerySpecification node) + { + if (node.FromClause != null && node.FromClause.TableReferences.Count > _maxJoins) + { + AddViolation(node.FromClause, $"Количество таблиц: {node.FromClause.TableReferences.Count}"); + } + base.Visit(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/ExecuteAsOwnerRule.cs b/SQLLinter/Infrastructure/Rules/ExecuteAsOwnerRule.cs new file mode 100644 index 0000000..5995a9a --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/ExecuteAsOwnerRule.cs @@ -0,0 +1,30 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class ExecuteAsOwnerRule : BaseRuleVisitor +{ + public override string Text => "Процедура должна содержать EXECUTE AS OWNER: {0}"; + + public override void Visit(CreateProcedureStatement node) => check(node); + public override void Visit(CreateOrAlterProcedureStatement node) => check(node); + public override void Visit(AlterProcedureStatement node) => check(node); + + private void check(ProcedureStatementBody node) + { + foreach (var option in node.Options) + { + if (option.OptionKind == ProcedureOptionKind.ExecuteAs + && option is ExecuteAsProcedureOption execOpt + && execOpt.ExecuteAs.ExecuteAsOption == ExecuteAsOption.Owner) + { + + return; + } + } + + AddViolation(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + } +} diff --git a/SQLLinter/Infrastructure/Rules/FullTextRule.cs b/SQLLinter/Infrastructure/Rules/FullTextRule.cs new file mode 100644 index 0000000..039c7a5 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/FullTextRule.cs @@ -0,0 +1,15 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class FullTextRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Обнаружен полнотекстовый предикат, это может вызвать проблемы с производительностью."; + + public override void Visit(FullTextPredicate node) + { + AddViolation(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/HavingWithoutAggregateRule.cs b/SQLLinter/Infrastructure/Rules/HavingWithoutAggregateRule.cs new file mode 100644 index 0000000..d9ba345 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/HavingWithoutAggregateRule.cs @@ -0,0 +1,42 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class HavingWithoutAggregateRule : BaseRuleVisitor +{ + public override string Text => "HAVING должен содержать агрегатные функции: {0}"; + + public override void Visit(HavingClause node) + { + bool hasAggregate = ContainsAggregate(node.SearchCondition); + + if (!hasAggregate) + { + AddViolation(node, node.SearchCondition?.ToString() ?? "HAVING без агрегатов"); + } + + base.Visit(node); + } + + private bool ContainsAggregate(TSqlFragment fragment) + { + var finder = new AggregateFinder(); + fragment.Accept(finder); + return finder.HasAggregate; + } + + private class AggregateFinder : TSqlFragmentVisitor + { + public bool HasAggregate { get; private set; } + + public override void Visit(FunctionCall node) + { + var name = node.FunctionName.Value.ToUpperInvariant(); + if (name is "COUNT" or "SUM" or "AVG" or "MIN" or "MAX") + HasAggregate = true; + + base.Visit(node); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/HavingWithoutGroupByRule.cs b/SQLLinter/Infrastructure/Rules/HavingWithoutGroupByRule.cs new file mode 100644 index 0000000..1de5130 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/HavingWithoutGroupByRule.cs @@ -0,0 +1,18 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class HavingWithoutGroupByRule : BaseRuleVisitor +{ + public override string Text => "HAVING без GROUP BY недопустим: {0}"; + + public override void Visit(QuerySpecification node) + { + if (node.HavingClause != null && node.GroupByClause == null) + { + AddViolation(node.HavingClause, node.HavingClause.ToString()); + } + base.Visit(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/HeaderCommentRule.cs b/SQLLinter/Infrastructure/Rules/HeaderCommentRule.cs new file mode 100644 index 0000000..a28670f --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/HeaderCommentRule.cs @@ -0,0 +1,46 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class HeaderCommentRule : BaseRuleVisitor +{ + public override string Text => "У процедур/функций/триггеров должна быть шапка-комментарий с автором, департаментом, назначением: {0}"; + + public override void Visit(CreateProcedureStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + + public override void Visit(CreateOrAlterProcedureStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + + public override void Visit(CreateFunctionStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.Name)); + + public override void Visit(CreateOrAlterFunctionStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.Name)); + + public override void Visit(CreateTriggerStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.Name)); + + public override void Visit(CreateOrAlterTriggerStatement node) => private_visit(node, SQLHelpers.ObjectGetFullName(node.Name)); + + public override void Visit(CreateViewStatement node) => private_visit(node, ""); + + public override void Visit(CreateOrAlterViewStatement node) => private_visit(node, ""); + + private void private_visit(TSqlFragment node, string name) + { + var prevTokenIndex = node.FirstTokenIndex; + TSqlParserToken? prevToken = null; + + while (prevTokenIndex > 0) + { + prevTokenIndex -= 1; + prevToken = node.ScriptTokenStream[prevTokenIndex]; + if (prevToken.TokenType != TSqlTokenType.WhiteSpace) break; + } + + if (prevToken == null || + prevToken.TokenType != TSqlTokenType.SingleLineComment && prevToken.TokenType != TSqlTokenType.MultilineComment + ) + { + AddViolation(node, name); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/IndexHintRule.cs b/SQLLinter/Infrastructure/Rules/IndexHintRule.cs new file mode 100644 index 0000000..9e9067c --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/IndexHintRule.cs @@ -0,0 +1,17 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class IndexHintRule : BaseRuleVisitor +{ + public override string Text => "Запрещено использование хинтов WITH (INDEX)."; + + public override void Visit(TableHint node) + { + if (node.HintKind == TableHintKind.Index) + { + AddViolation(Name, Text, GetLineNumber(node), GetColumnNumber(node)); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/InformationSchemaRule.cs b/SQLLinter/Infrastructure/Rules/InformationSchemaRule.cs new file mode 100644 index 0000000..b996a72 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/InformationSchemaRule.cs @@ -0,0 +1,190 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class InformationSchemaRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Ожидается использование SYS.Partitions вместо представлений INFORMATION SCHEMA."; + + public override void Visit(SchemaObjectName node) + { + var schemaIdentifier = node.SchemaIdentifier?.Value != null; + + if (schemaIdentifier && node.SchemaIdentifier.Value.Equals("INFORMATION_SCHEMA", StringComparison.InvariantCultureIgnoreCase)) + { + AddViolation(node); + } + } + + public override void FixViolation(List fileLines, IRuleViolation ruleViolation, FileLineActions actions) + { + var node = FixHelpers.FindNodes(fileLines, x => x.StartLine == ruleViolation.Line); + + if (node.Count == 1) + { + switch (node[0]) + { + case IfStatement ifStatement: + HandleFragment(actions, ifStatement.Predicate); + break; + + case SelectStatement selectStatement: + HandleFragment(actions, selectStatement); + break; + } + } + } + + private static void HandleFragment(FileLineActions actions, TSqlFragment statement) + { + var fromClauses = FixHelpers.FindNodes(statement); + var whereClauses = FixHelpers.FindNodes(statement); + + if (fromClauses.Count == 1 && whereClauses.Count <= 1) + { + var fromClause = fromClauses[0]; + var whereClause = whereClauses.FirstOrDefault(); + var tableName = FixHelpers.FindNodes(fromClause)[0].BaseIdentifier.Value; + string newFrom = null; + + switch (tableName) + { + case "TABLES": + newFrom = "FROM sys.tables"; + UpdateWhereTable(actions, fromClause, whereClause); + + break; + + case "ROUTINES": + newFrom = "FROM sys.procedures"; + UpdateWhereRoutine(actions, fromClause, whereClause); + break; + + case "COLUMNS": + newFrom = "FROM sys.columns"; + UpdateWhereColumns(actions, fromClause, whereClause); + break; + + default: + break; + } + + string oldFrom = FixHelpers.GetString(fromClause); + + if (newFrom != null) + { + actions.RepaceInlineAt(fromClause.StartLine - 1, fromClause.StartColumn - 1, + newFrom, oldFrom.Length); + } + } + } + + private static string GetWhereColumnValueName(WhereClause whereClause, string columnName) + { + var node = FixHelpers.FindNodes(whereClause, + x => FixHelpers.FindNodes(x.FirstExpression)[0].Value == columnName); + + if (node.Count == 1 && node[0].SecondExpression is StringLiteral stringLiteral) + { + return stringLiteral.Value; + } + + return null; + } + + private static void UpdateWhere(FileLineActions actions, FromClause fromClause, WhereClause whereClause, string newWhere) + { + var firstWhereLine = whereClause.ScriptTokenStream[whereClause.FirstTokenIndex].Line; + var lastWhereLine = whereClause.ScriptTokenStream[whereClause.LastTokenIndex].Line; + + // Delete mutliline where + var anyDeleted = false; + for (int line = lastWhereLine; line > firstWhereLine; line--) + { + actions.RemoveAt(line - 1); + anyDeleted = true; + } + if (anyDeleted) + { + newWhere += ")"; + } + + var oldWhere = string.Join(string.Empty, whereClause.ScriptTokenStream + .Where((x, i) => x.Line == firstWhereLine + && x.Column >= whereClause.StartColumn + && i <= whereClause.LastTokenIndex + && x.Text != "\n") + .Select(x => x.Text)); + + actions.RepaceInlineAt(whereClause.StartLine - 1, whereClause.StartColumn - 1, + newWhere, oldWhere.Length); + } + + private static void UpdateWhereColumns( + FileLineActions actions, + FromClause fromClause, + WhereClause whereClause) + { + if (whereClause != null) + { + var schema = GetWhereColumnValueName(whereClause, "TABLE_SCHEMA"); + var columnName = GetWhereColumnValueName(whereClause, "COLUMN_NAME"); + var tableName = GetWhereColumnValueName(whereClause, "TABLE_NAME"); + var dataType = GetWhereColumnValueName(whereClause, "DATA_TYPE"); + + if (schema != null && tableName != null && columnName != null) + { + var newWhere = $"WHERE [object_id] = OBJECT_ID(N'{schema}.{tableName}') AND [name] = '{columnName}'"; + if (dataType != null) + { + newWhere += $" AND [system_type_id] = TYPE_ID(N'{dataType}')"; + } + + UpdateWhere(actions, fromClause, whereClause, newWhere); + } + } + } + + private static void UpdateWhereRoutine( + FileLineActions actions, + FromClause fromClause, + WhereClause whereClause) + { + if (whereClause != null) + { + var schema = GetWhereColumnValueName(whereClause, "ROUTINE_SCHEMA"); + var name = GetWhereColumnValueName(whereClause, "ROUTINE_NAME"); + var type = GetWhereColumnValueName(whereClause, "ROUTINE_TYPE"); + + if (type == "PROCEDURE" && schema != null && name != null) + { + var newWhere = $"WHERE [object_id] = OBJECT_ID(N'{schema}.{name}')"; + + UpdateWhere(actions, fromClause, whereClause, newWhere); + } + } + } + + private static void UpdateWhereTable( + FileLineActions actions, + FromClause fromClause, + WhereClause whereClause) + { + if (whereClause != null) + { + var schema = GetWhereColumnValueName(whereClause, "TABLE_SCHEMA"); + var name = GetWhereColumnValueName(whereClause, "TABLE_NAME"); + var type = GetWhereColumnValueName(whereClause, "TABLE_TYPE"); + + if (type == "BASE TABLE" && schema != null && name != null) + { + var newWhere = $"WHERE [object_id] = OBJECT_ID(N'{schema}.{name}')"; + + UpdateWhere(actions, fromClause, whereClause, newWhere); + } + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/InnerJoinRule.cs b/SQLLinter/Infrastructure/Rules/InnerJoinRule.cs new file mode 100644 index 0000000..7bd8cb2 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/InnerJoinRule.cs @@ -0,0 +1,45 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class InnerJoinRule : BaseRuleVisitor +{ + public override string Text => "Используйте полную запись INNER JOIN."; + + public override void Visit(QualifiedJoin node) + { + if (node.QualifiedJoinType != QualifiedJoinType.Inner) + return; + + var tokens = node.ScriptTokenStream; + if (tokens == null) return; + + var secondIndex = node.SecondTableReference.FirstTokenIndex; + + int start = node.FirstTableReference.LastTokenIndex; + int end = node.SecondTableReference.FirstTokenIndex; + + bool hasInner = tokens + .Skip(start) + .Take(end - start + 1) + .Any(t => t.TokenType == TSqlTokenType.Inner); + + if (!hasInner) + { + var joinToken = tokens + .Skip(start) + .Take(end - start + 1) + .FirstOrDefault(t => t.TokenType == TSqlTokenType.Join); + + if (joinToken != null) + { + AddViolation(Name, Text, joinToken.Line, joinToken.Column); + } + else + { + AddViolation(node); + } + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/InsertStarRule.cs b/SQLLinter/Infrastructure/Rules/InsertStarRule.cs new file mode 100644 index 0000000..2578f9b --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/InsertStarRule.cs @@ -0,0 +1,17 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class InsertStarRule : BaseRuleVisitor +{ + public override string Text => "Запрещено INSERT без столбцов."; + + public override void Visit(InsertStatement node) + { + if (node.InsertSpecification.Columns.Count == 0) // INSERT без перечисления колонок + { + AddViolation(node); + } + } +} \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Rules/InsertValuesInsteadOfSelectRule.cs b/SQLLinter/Infrastructure/Rules/InsertValuesInsteadOfSelectRule.cs new file mode 100644 index 0000000..834363e --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/InsertValuesInsteadOfSelectRule.cs @@ -0,0 +1,26 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class InsertValuesInsteadOfSelectRule : BaseRuleVisitor +{ + public override string Text => "Для вставки константных значений используйте VALUES(...)"; + + public override void Visit(InsertStatement node) + { + // Проверяем, что источник данных - SELECT + if (node.InsertSpecification.InsertSource is SelectInsertSource selectSource) + { + var query = selectSource.Select as QuerySpecification; + if (query != null) + { + // Если в SELECT нет таблиц (т.е. просто SELECT 1,2,3) + if (query.FromClause == null || query.FromClause.TableReferences.Count == 0) + { + AddViolation(Name, Text, GetLineNumber(node), GetColumnNumber(node)); + } + } + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/JoinKeywordRule.cs b/SQLLinter/Infrastructure/Rules/JoinKeywordRule.cs new file mode 100644 index 0000000..da30b80 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/JoinKeywordRule.cs @@ -0,0 +1,38 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class JoinKeywordRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Вместо неявного синтаксиса (соединения через запятую) следует использовать ключевое слово join. Замените соединения через запятую синтаксисом «INNER JOIN»."; + + public override void Visit(FromClause node) + { + // Проверьте, используются ли в соединении запятые (синтаксис неявного соединения). + if (node.TableReferences.Count > 1) + { + for (int i = 0; i < node.TableReferences.Count; i++) + { + if (node.TableReferences[i] is QualifiedJoin) + { + // Пропустить, если это правильное ПРИСОЕДИНЕНИЕ + continue; + } + if (i < node.TableReferences.Count - 1) + { + // Если следующая ссылка на таблицу не является соединением, это соединение через запятую. + if (!(node.TableReferences[i + 1] is QualifiedJoin)) + { + AddViolation(node); + break; + } + } + } + } + + base.Visit(node); + } + +} diff --git a/SQLLinter/Infrastructure/Rules/KeywordCapitalizationRule.cs b/SQLLinter/Infrastructure/Rules/KeywordCapitalizationRule.cs new file mode 100644 index 0000000..7df162e --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/KeywordCapitalizationRule.cs @@ -0,0 +1,59 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Core; +using SQLLinter.Infrastructure.Rules.Common; +using System.Text.RegularExpressions; + +namespace SQLLinter.Infrastructure.Rules; + +public class KeywordCapitalizationRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Ожидается, что ключевое слово TSQL будет написано в верхнем регистре: {0}"; + + public override void Visit(TSqlScript node) + { + var typesToUpcase = Constants.TSqlKeywords.Concat(Constants.TSqlDataTypes).ToArray(); + for (var index = 0; index < node.ScriptTokenStream?.Count; index++) + { + var token = node.ScriptTokenStream[index]; + if (!typesToUpcase.Contains(token.Text, StringComparer.CurrentCultureIgnoreCase)) + { + continue; + } + + if (IsUpperCase(token.Text)) + { + continue; + } + + var dynamicSQLAdjustment = GetDynamicSqlColumnOffset(token); + + // получить количество всех вкладок в строке, которые встречаются до последнего токена в этом узле + var tabsOnLine = ColumnNumberCalculator.CountTabsBeforeToken(token.Line, index, node.ScriptTokenStream); + var column = ColumnNumberCalculator.GetColumnNumberBeforeToken(tabsOnLine, token); + + AddViolation(Name, GetText(token.Text), GetLineNumber(token), column + dynamicSQLAdjustment); + } + } + + public override void FixViolation(List fileLines, IRuleViolation ruleViolation, FileLineActions actions) + { + var lineIndex = ruleViolation.Line - 1; + var line = fileLines[lineIndex]; + + var startCharIndex = ColumnNumberCalculator.GetIndex(line, ruleViolation.Column); + + if (startCharIndex != -1) + { + var errorWord = new Regex(@"\w+").Matches(line[startCharIndex..]).First().Value; + + actions.RepaceInlineAt(lineIndex, startCharIndex, errorWord.ToUpper()); + } + } + + private static bool IsUpperCase(string input) + { + return input.All(t => !char.IsLetter(t) || char.IsUpper(t)); + } +} diff --git a/SQLLinter/Infrastructure/Rules/LinkedServerReferenceRule.cs b/SQLLinter/Infrastructure/Rules/LinkedServerReferenceRule.cs new file mode 100644 index 0000000..9bd8c71 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/LinkedServerReferenceRule.cs @@ -0,0 +1,19 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class LinkedServerReferenceRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Запрещены межсерверные запросы: {0}"; + + public override void Visit(NamedTableReference node) + { + if (node.SchemaObject.ServerIdentifier != null) + { + AddViolation(node, node.SchemaObject.ServerIdentifier.Value); + } + } + +} diff --git a/SQLLinter/Infrastructure/Rules/MultiTableAliasRule.cs b/SQLLinter/Infrastructure/Rules/MultiTableAliasRule.cs new file mode 100644 index 0000000..d410be7 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/MultiTableAliasRule.cs @@ -0,0 +1,97 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; +using SQLLinter.Infrastructure.Rules.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class MultiTableAliasRule : BaseRuleVisitor, IRule +{ + private HashSet cteNames = new HashSet(); + + + public override string Text => "Найдена таблица без псевдонимов при объединении нескольких таблиц: {0}"; + + public override void Visit(TSqlStatement node) + { + var childCommonTableExpressionVisitor = new ChildCommonTableExpressionVisitor(); + node.AcceptChildren(childCommonTableExpressionVisitor); + cteNames = childCommonTableExpressionVisitor.CommonTableExpressionIdentifiers; + } + + public override void Visit(TableReference node) + { + void ChildCallback(TSqlFragment childNode) + { + var dynamicSqlAdjustment = GetDynamicSqlColumnOffset(childNode); + var tabsOnLine = ColumnNumberCalculator.CountTabsBeforeToken(childNode.StartLine, childNode.LastTokenIndex, childNode.ScriptTokenStream); + var column = ColumnNumberCalculator.GetColumnNumberBeforeToken(tabsOnLine, childNode.ScriptTokenStream[childNode.FirstTokenIndex]); + + string tableName = ""; + + if (childNode is NamedTableReference namedTable) + { + tableName = SQLHelpers.ObjectGetFullName(namedTable.SchemaObject); + } + + AddViolation(Name, GetText(tableName), GetLineNumber(childNode), column + dynamicSqlAdjustment); + } + + var childTableJoinVisitor = new ChildTableJoinVisitor(); + node.AcceptChildren(childTableJoinVisitor); + + if (!childTableJoinVisitor.TableJoined) + { + return; + } + + var childTableAliasVisitor = new ChildTableAliasVisitor(ChildCallback, cteNames); + node.AcceptChildren(childTableAliasVisitor); + } + + public class ChildCommonTableExpressionVisitor : TSqlFragmentVisitor + { + public HashSet CommonTableExpressionIdentifiers { get; } = new HashSet(); + + public override void Visit(CommonTableExpression node) + { + CommonTableExpressionIdentifiers.Add(node.ExpressionName.Value); + } + } + + public class ChildTableJoinVisitor : TSqlFragmentVisitor + { + public bool TableJoined { get; private set; } + + public override void Visit(JoinTableReference node) + { + TableJoined = true; + } + } + + public class ChildTableAliasVisitor : TSqlFragmentVisitor + { + private readonly Action childCallback; + + public ChildTableAliasVisitor(Action errorCallback, HashSet cteNames) + { + CteNames = cteNames; + childCallback = errorCallback; + } + + public HashSet CteNames { get; } + + public override void Visit(NamedTableReference node) + { + if (CteNames.Contains(node.SchemaObject.BaseIdentifier.Value)) + { + return; + } + + if (node.Alias == null) + { + childCallback(node); + } + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/NamedConstraintRule.cs b/SQLLinter/Infrastructure/Rules/NamedConstraintRule.cs new file mode 100644 index 0000000..89a2451 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/NamedConstraintRule.cs @@ -0,0 +1,47 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class NamedConstraintRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Именованные ограничения во временных таблицах могут вызывать коллизии при параллельном запуске: {0}"; + + public override void Visit(CreateTableStatement node) + { + // применять правило только к временным таблицам + if (!node.SchemaObjectName.BaseIdentifier.Value.Contains("#")) + { + return; + } + + var constraintVisitor = new ConstraintVisitor(); + node.AcceptChildren(constraintVisitor); + + if (constraintVisitor.NamedConstraintExists) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.SchemaObjectName)); + } + } + + private class ConstraintVisitor : TSqlFragmentVisitor + { + public bool NamedConstraintExists + { + get; + private set; + } + + public override void Visit(ConstraintDefinition node) + { + if (NamedConstraintExists) + { + return; + } + + NamedConstraintExists = node.ConstraintIdentifier != null; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/NestedSubqueryDepthRule.cs b/SQLLinter/Infrastructure/Rules/NestedSubqueryDepthRule.cs new file mode 100644 index 0000000..0ab22a5 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/NestedSubqueryDepthRule.cs @@ -0,0 +1,36 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class NestedSubqueryDepthRule : BaseRuleVisitor +{ + public override string Text => "Слишком глубокие подзапросы (>" + _maxDepth + "} уровней): {0}"; + + private int _depth = 0; + private const int _maxDepth = 3; + + public override void Visit(QueryDerivedTable node) + { + _depth++; + if (_depth > _maxDepth) + { + AddViolation(node, $"Глубина подзапроса {_depth}"); + } + node.QueryExpression.Accept(this); + _depth--; + base.Visit(node); + } + + public override void Visit(ScalarSubquery node) + { + _depth++; + if (_depth > _maxDepth) + { + AddViolation(node, $"Глубина подзапроса {_depth}"); + } + node.QueryExpression.Accept(this); + _depth--; + base.Visit(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/NoLockRule.cs b/SQLLinter/Infrastructure/Rules/NoLockRule.cs new file mode 100644 index 0000000..74807bb --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/NoLockRule.cs @@ -0,0 +1,17 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class NoLockRule : BaseRuleVisitor +{ + public override string Text => "Запрещено использование NOLOCK."; + + public override void Visit(TableHint node) + { + if (node.HintKind == TableHintKind.NoLock) + { + AddViolation(node); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/NonSargableRule.cs b/SQLLinter/Infrastructure/Rules/NonSargableRule.cs new file mode 100644 index 0000000..e99991c --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/NonSargableRule.cs @@ -0,0 +1,151 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Infrastructure.Rules.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class NonSargableRule : BaseRuleVisitor, IRule +{ + private readonly List errorsReported = new(); + + public override string Text => "Выполнение функций с предложениями фильтра или предикатами соединения может вызвать проблемы с производительностью."; + + public override void Visit(JoinTableReference node) + { + var predicateExpressionVisitor = new PredicateVisitor(); + node.AcceptChildren(predicateExpressionVisitor); + var multiClauseQuery = predicateExpressionVisitor.PredicatesFound; + + var joinVisitor = new JoinQueryVisitor(VisitorCallback, multiClauseQuery); + node.AcceptChildren(joinVisitor); + } + + public override void Visit(WhereClause node) + { + var predicateExpressionVisitor = new PredicateVisitor(); + node.Accept(predicateExpressionVisitor); + var multiClauseQuery = predicateExpressionVisitor.PredicatesFound; + + var childVisitor = new FunctionVisitor(VisitorCallback, multiClauseQuery); + node.Accept(childVisitor); + } + + private void VisitorCallback(TSqlFragment childNode) + { + if (errorsReported.Contains(childNode)) + { + return; + } + + var dynamicSqlColumnAdjustment = GetDynamicSqlColumnOffset(childNode); + + errorsReported.Add(childNode); + AddViolation(Name, Text, GetLineNumber(childNode), ColumnNumberCalculator.GetNodeColumnPosition(childNode) + dynamicSqlColumnAdjustment); + } + + private class JoinQueryVisitor : TSqlFragmentVisitor + { + private readonly Action childCallback; + private readonly bool isMultiClauseQuery; + + public JoinQueryVisitor(Action childCallback, bool multiClauseQuery) + { + this.childCallback = childCallback; + isMultiClauseQuery = multiClauseQuery; + } + + public override void Visit(BooleanComparisonExpression node) + { + var childVisitor = new FunctionVisitor(childCallback, isMultiClauseQuery); + node.Accept(childVisitor); + } + } + + private class PredicateVisitor : TSqlFragmentVisitor + { + public bool PredicatesFound { get; private set; } + + public override void Visit(BooleanBinaryExpression node) + { + PredicatesFound = true; + } + } + + private class FunctionVisitor : TSqlFragmentVisitor + { + private readonly bool isMultiClause; + private readonly Action childCallback; + private bool hasColumnReferenceParameter; + + public FunctionVisitor(Action errorCallback, bool isMultiClause) + { + childCallback = errorCallback; + this.isMultiClause = isMultiClause; + } + + public override void Visit(FunctionCall node) + { + switch (node.FunctionName.Value.ToUpper()) + { + // разрешить предикаты isnull при наличии других фильтров + case "ISNULL" when isMultiClause: + return; + case "DATEADD": + case "DATEDIFF": + case "DATEDIFF_BIG": + case "DATENAME": + case "DATEPART": + case "DATETRUNC": + case "DATE_BUCKET": + hasColumnReferenceParameter = true; + break; + } + + FindColumnReferences(node); + } + + public override void Visit(LeftFunctionCall node) + { + FindColumnReferences(node); + } + + public override void Visit(RightFunctionCall node) + { + FindColumnReferences(node); + } + + public override void Visit(ConvertCall node) + { + FindColumnReferences(node); + } + + public override void Visit(CastCall node) + { + FindColumnReferences(node); + } + + private void FindColumnReferences(TSqlFragment node) + { + var columnReferenceVisitor = new ColumnReferenceVisitor(); + node.AcceptChildren(columnReferenceVisitor); + + if (columnReferenceVisitor.ColumnReferenceFound && (!hasColumnReferenceParameter || columnReferenceVisitor.ColumnReferenceCount > 1)) + { + childCallback(node); + } + } + } + + private class ColumnReferenceVisitor : TSqlFragmentVisitor + { + public bool ColumnReferenceFound { get; private set; } + + public int ColumnReferenceCount { get; private set; } + + public override void Visit(ColumnReferenceExpression node) + { + ColumnReferenceCount++; + ColumnReferenceFound = true; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/NullComparisonRule.cs b/SQLLinter/Infrastructure/Rules/NullComparisonRule.cs new file mode 100644 index 0000000..440f378 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/NullComparisonRule.cs @@ -0,0 +1,20 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class NullComparisonRule : BaseRuleVisitor +{ + public override string Text => "Запрещено сравнение с NULL через '='. Используйте 'IS'': {0}"; + public override void Visit(BooleanComparisonExpression node) + { + // Если один из операндов - NULL + if (node.FirstExpression is NullLiteral || node.SecondExpression is NullLiteral) + { + // Это значит, что используется = и т.п. с NULL + AddViolation(node, node.ToString()); + } + + base.Visit(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/ObjectPropertyRule.cs b/SQLLinter/Infrastructure/Rules/ObjectPropertyRule.cs new file mode 100644 index 0000000..887b372 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/ObjectPropertyRule.cs @@ -0,0 +1,18 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class ObjectPropertyRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Ожидается использование SYS.COLUMNS вместо функции свойства объекта."; + + public override void Visit(FunctionCall node) + { + if (node.FunctionName.Value.Equals("OBJECTPROPERTY", StringComparison.OrdinalIgnoreCase)) + { + AddViolation(node); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/OrderByWithoutTopOffsetRule.cs b/SQLLinter/Infrastructure/Rules/OrderByWithoutTopOffsetRule.cs new file mode 100644 index 0000000..ae18c88 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/OrderByWithoutTopOffsetRule.cs @@ -0,0 +1,30 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using System.Linq; + +namespace SQLLinter.Infrastructure.Rules; + +public class OrderByWithoutTopOffsetRule : BaseRuleVisitor +{ + public override string Text => "Обнаружен ORDER BY без TOP или OFFSET: {0}"; + + public override void Visit(QuerySpecification node) + { + if (node.OrderByClause != null && node.TopRowFilter == null && node.OffsetClause == null) + { + var tokens = node.OrderByClause.ScriptTokenStream + .Skip(node.OrderByClause.FirstTokenIndex) + .Take(node.OrderByClause.LastTokenIndex - node.OrderByClause.FirstTokenIndex + 1) + .Select(t => t.TokenType == TSqlTokenType.WhiteSpace ? " " : t.Text); + + var orderByString = string.Join("", tokens); + if (orderByString.Length > 53) + { + orderByString = orderByString[..50] + "..."; + } + + AddViolation(node.OrderByClause, orderByString); + } + base.Visit(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/PrintStatementRule.cs b/SQLLinter/Infrastructure/Rules/PrintStatementRule.cs new file mode 100644 index 0000000..7fdcbdc --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/PrintStatementRule.cs @@ -0,0 +1,15 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class PrintStatementRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Оператор PRINT найден."; + + public override void Visit(PrintStatement node) + { + AddViolation(node); + } +} diff --git a/SQLLinter/Infrastructure/Rules/ProcedureLoggingRule.cs b/SQLLinter/Infrastructure/Rules/ProcedureLoggingRule.cs new file mode 100644 index 0000000..14c0fc8 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/ProcedureLoggingRule.cs @@ -0,0 +1,87 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class ProcedureLoggingRule : BaseRuleVisitor +{ + public override string Text => "В процедурах обязательно логирование (@DebugLog, LABEL_FINISH): {0}"; + + public override void Visit(CreateProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + public override void Visit(CreateOrAlterProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + public override void Visit(AlterProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + + private void check(TSqlStatement node, string name) + { + var tokens = node.ScriptTokenStream; + + bool hasDebugLog = false; + bool hasLabelFinish = false; + + foreach (var token in tokens) + { + if (token.Text is null) continue; + + if (token.Text.Equals("@DebugLog", StringComparison.OrdinalIgnoreCase)) + { + hasDebugLog = true; + } + else if (token.Text.Equals("LABEL_FINISH:", StringComparison.OrdinalIgnoreCase)) + { + hasLabelFinish = true; + } + } + + if (!hasDebugLog || !hasLabelFinish) + { + AddViolation(node, name); + } + } +} + +public class ProcedureLoggingReturnRule : BaseRuleVisitor +{ + public override string Text => "В процедурах с логированием RETURN запрещён: {0}"; + + public override void Visit(CreateProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + public override void Visit(CreateOrAlterProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + public override void Visit(AlterProcedureStatement node) => check(node, SQLHelpers.ObjectGetFullName(node.ProcedureReference.Name)); + + private void check(TSqlStatement node, string name) + { + var tokens = node.ScriptTokenStream; + + bool hasDebugLog = false; + bool hasLabelFinish = false; + bool hasReturn = false; + + List returnPositions = new(); + + foreach (var token in tokens) + { + if (token.Text is null) continue; + + if (token.Text.Equals("@DebugLog", StringComparison.OrdinalIgnoreCase)) + { + hasDebugLog = true; + } + else if (token.Text.Equals("LABEL_FINISH:", StringComparison.OrdinalIgnoreCase)) + { + hasLabelFinish = true; + } + else if (token.Text.Equals("RETURN", StringComparison.OrdinalIgnoreCase)) + { + hasReturn = true; + returnPositions.Add(new(Line: token.Line, Column: token.Column)); + } + } + + if ((hasDebugLog || hasLabelFinish) || hasReturn) + { + returnPositions.ForEach(t => AddViolation(Name, GetText(name), t.Line, t.Column)); + } + } + + private record ReturnPosition(int Line, int Column); +} diff --git a/SQLLinter/Infrastructure/Rules/RecompileRule.cs b/SQLLinter/Infrastructure/Rules/RecompileRule.cs new file mode 100644 index 0000000..05b41b8 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/RecompileRule.cs @@ -0,0 +1,17 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class RecompileRule : BaseRuleVisitor +{ + public override string Text => "Нежелательно использовать RECOMPILE."; + + public override void Visit(OptimizeForOptimizerHint node) + { + if (node.HintKind == OptimizerHintKind.Recompile) + { + AddViolation(node); + } + } +} \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Rules/RuleExceptions/GlobalRuleException.cs b/SQLLinter/Infrastructure/Rules/RuleExceptions/GlobalRuleException.cs new file mode 100644 index 0000000..b562b85 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/RuleExceptions/GlobalRuleException.cs @@ -0,0 +1,24 @@ +using SQLLinter.Core.Interfaces; + +namespace SQLLinter.Infrastructure.Rules.RuleExceptions +{ + public class GlobalRuleException : IExtendedRuleException + { + public GlobalRuleException(int startLine, int endLine) + { + EndLine = endLine; + StartLine = startLine; + } + + public int EndLine { get; private set; } + + public string RuleName => "Global"; + + public int StartLine { get; } + + public void SetEndLine(int endLine) + { + EndLine = endLine; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/RuleExceptions/RuleException.cs b/SQLLinter/Infrastructure/Rules/RuleExceptions/RuleException.cs new file mode 100644 index 0000000..09f04fb --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/RuleExceptions/RuleException.cs @@ -0,0 +1,28 @@ +using SQLLinter.Core.Interfaces; + +namespace SQLLinter.Infrastructure.Rules.RuleExceptions +{ + public class RuleException : IExtendedRuleException + { + public RuleException(Type ruleType, string ruleName, int startLine, int endLine) + { + RuleType = ruleType; + RuleName = ruleName; + StartLine = startLine; + EndLine = endLine; + } + + public Type RuleType { get; } + + public int StartLine { get; } + + public int EndLine { get; private set; } + + public string RuleName { get; } + + public void SetEndLine(int endLine) + { + EndLine = endLine; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/RuleExceptions/RuleExceptionFinder.cs b/SQLLinter/Infrastructure/Rules/RuleExceptions/RuleExceptionFinder.cs new file mode 100644 index 0000000..e087697 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/RuleExceptions/RuleExceptionFinder.cs @@ -0,0 +1,98 @@ +using SQLLinter.Common; +using SQLLinter.Core; +using SQLLinter.Core.Interfaces; +using System.Text.RegularExpressions; + +namespace SQLLinter.Infrastructure.Rules.RuleExceptions +{ + public class RuleExceptionFinder : IRuleExceptionFinder + { + public static Regex RuleExceptionRegex = new Regex(@"(sqllinter-(?:dis|en)able)\s*(.*)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly IDictionary Rules; + + public RuleExceptionFinder(IDictionary rules) + { + Rules = rules; + } + + public IEnumerable GetIgnoredRuleList(Stream fileStream) + { + var ruleExceptionList = new List(); + TextReader reader = new StreamReader(fileStream); + + var lineNumber = 0; + string line; + while ((line = reader.ReadLine()) != null) + { + lineNumber++; + if (line.Length > Constants.MaxLineWidthForRegexEval || !line.Contains("sqllinter-")) + { + continue; + } + + var match = RuleExceptionRegex.Match(line); + if (!match.Success) + { + continue; + } + + FindIgnoredRules(ruleExceptionList, lineNumber, match); + } + + fileStream.Seek(0, SeekOrigin.Begin); + + foreach (var ruleException in ruleExceptionList) + { + if (ruleException.EndLine == 0) + { + ruleException.SetEndLine(lineNumber); + } + } + + return ruleExceptionList; + } + + private void FindIgnoredRules(ICollection ruleExceptionList, int lineNumber, Match match) + { + var action = match.Groups[1].Value; + + var disableCommand = action.Equals("sqllinter-disable", StringComparison.OrdinalIgnoreCase); + var enableCommand = action.Equals("sqllinter-enable", StringComparison.OrdinalIgnoreCase); + + var ruleExceptionDetails = match.Groups[2].Value.Split(' ').Select(p => p.Trim()).ToList(); + var matchedFriendlyNames = ruleExceptionDetails.Intersect(Rules.Keys).ToList(); + + if (!matchedFriendlyNames.Any()) + { + if (disableCommand) + { + var ruleException = new GlobalRuleException(lineNumber, 0); + ruleExceptionList.Add(ruleException); + } + + if (enableCommand) + { + var ruleException = ruleExceptionList.OfType().FirstOrDefault(r => r.EndLine == 0); + ruleException?.SetEndLine(lineNumber); + } + } + + foreach (var matchedFriendlyName in matchedFriendlyNames) + { + Rules.TryGetValue(matchedFriendlyName, out var matched); + + if (disableCommand) + { + var ruleException = new RuleException(matched.GetType(), matchedFriendlyName, lineNumber, 0); + ruleExceptionList.Add(ruleException); + } + + if (enableCommand) + { + var ruleException = ruleExceptionList.OfType().FirstOrDefault(r => r.RuleName == matchedFriendlyName && r.EndLine == 0); + ruleException?.SetEndLine(lineNumber); + } + } + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/RuleViolations/RuleViolation.cs b/SQLLinter/Infrastructure/Rules/RuleViolations/RuleViolation.cs new file mode 100644 index 0000000..5b6c6ae --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/RuleViolations/RuleViolation.cs @@ -0,0 +1,49 @@ +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules.RuleViolations +{ + public class RuleViolation : IRuleViolation + { + public RuleViolation(string fileName, string ruleName, string text, int startLine, int startColumn, RuleViolationSeverity severity) + { + FileName = fileName; + RuleName = ruleName; + Text = text; + Line = startLine; + Column = startColumn; + Severity = severity; + } + + public RuleViolation(string fileName, string ruleName, int startLine, int startColumn) + { + FileName = fileName; + RuleName = ruleName; + Line = startLine; + Column = startColumn; + } + + public RuleViolation(string ruleName, int startLine, int startColumn) + { + RuleName = ruleName; + Line = startLine; + Column = startColumn; + } + + public int Column { get; set; } + + public string FileName { get; set; } + + public int Line { get; set; } + + public string RuleName { get; set; } + + public RuleViolationSeverity Severity { get; set; } + + public string Text { get; set; } + + public override string ToString() + { + return $@"{Severity.ToString().ToUpper()}: L{Line} C{Column} {FileName} ""{Text}"""; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/SchemaQualifyRule.cs b/SQLLinter/Infrastructure/Rules/SchemaQualifyRule.cs new file mode 100644 index 0000000..6a50d86 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/SchemaQualifyRule.cs @@ -0,0 +1,80 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class SchemaQualifyRule : BaseRuleVisitor, IRule +{ + private readonly List tableAliases = new() + { + "INSERTED", + "UPDATED", + "DELETED" + }; + + public override string Text => "Имя объекта без схемы: {0}"; + + public override void Visit(TSqlStatement node) + { + var childAliasVisitor = new ChildAliasVisitor(); + node.AcceptChildren(childAliasVisitor); + tableAliases.AddRange(childAliasVisitor.TableAliases); + } + + public override void Visit(NamedTableReference node) => VisitTableName(node.SchemaObject, true); + + public override void Visit(CreateTableStatement node) => VisitTableName(node.SchemaObjectName, false); + + public override void Visit(AlterTableStatement node) => VisitTableName(node.SchemaObjectName, false); + + public override void Visit(TruncateTableStatement node) => VisitTableName(node.TableName, false); + + public override void Visit(DropTableStatement node) + { + foreach (var schemaObjectName in node.Objects) + { + VisitTableName(schemaObjectName, false); + } + } + + private void VisitTableName(SchemaObjectName node, bool canHaveTableAliases) + { + if (node.SchemaIdentifier != null) + { + return; + } + + // не проверять схему во временных таблицах + if (node.BaseIdentifier.Value.Contains('#')) + { + return; + } + + // не проверять схему псевдонимов таблиц + if (canHaveTableAliases && tableAliases.Exists(x => x.Equals(node.BaseIdentifier.Value, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + AddViolation(node, SQLHelpers.ObjectGetFullName(node)); + } + + public class ChildAliasVisitor : TSqlFragmentVisitor + { + public List TableAliases { get; } = new(); + + public override void Visit(TableReferenceWithAlias node) + { + if (node.Alias != null) + { + TableAliases.Add(node.Alias.Value); + } + } + + public override void Visit(CommonTableExpression node) + { + TableAliases.Add(node.ExpressionName.Value); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/SelectStarRule.cs b/SQLLinter/Infrastructure/Rules/SelectStarRule.cs new file mode 100644 index 0000000..595e532 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/SelectStarRule.cs @@ -0,0 +1,39 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class SelectStarRule : BaseRuleVisitor, IRule +{ + private int expressionCounter; + + public override string Text => "Ожидаются имена столбцов в SELECT"; + + public override void Visit(ExistsPredicate node) + { + var childVisitor = new ChildVisitor(); + node.AcceptChildren(childVisitor); + expressionCounter += childVisitor.SelectStarExpressionCount; + } + + public override void Visit(SelectStarExpression node) + { + if (expressionCounter > 0) + { + expressionCounter--; + return; + } + + AddViolation(node); + } + + public class ChildVisitor : TSqlFragmentVisitor + { + public int SelectStarExpressionCount { get; set; } + + public override void Visit(SelectStarExpression node) + { + SelectStarExpressionCount++; + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/SemicolonTerminationRule.cs b/SQLLinter/Infrastructure/Rules/SemicolonTerminationRule.cs new file mode 100644 index 0000000..0d065b2 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/SemicolonTerminationRule.cs @@ -0,0 +1,73 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Infrastructure.Rules.Common; +using System.Text.RegularExpressions; + +namespace SQLLinter.Infrastructure.Rules; + +public class SemicolonTerminationRule : BaseRuleVisitor, IRule +{ + private readonly IList waitForStatements = new List(); + private readonly IList functionReturnTypeSelectStatements = new List(); + private static Regex WhiteSpaceRegex = new Regex(@"\s", RegexOptions.Compiled); + private static Regex AllWhiteSpaceRegex = new Regex(@"^\s$", RegexOptions.Compiled); + + + // не принудительно завершать эти операторы точкой с запятой + private readonly Type[] typesToSkip = + { + typeof(BeginEndBlockStatement), + typeof(GoToStatement), + typeof(IndexDefinition), + typeof(LabelStatement), + typeof(WhileStatement), + typeof(IfStatement), + typeof(CreateViewStatement) + }; + + public override string Text => "Оператор не заканчивается точкой с запятой"; + + public override void Visit(WaitForStatement node) + { + waitForStatements.Add(node.Statement); + } + + public override void Visit(CreateFunctionStatement node) + { + if (node.ReturnType is SelectFunctionReturnType returnType) + { + functionReturnTypeSelectStatements.Add(returnType.SelectStatement); + } + } + + public override void Visit(TSqlStatement node) + { + if (Array.IndexOf(typesToSkip, node.GetType()) > -1 || + EndsWithSemicolon(node) || + waitForStatements.Contains(node) || + functionReturnTypeSelectStatements.Contains(node)) + { + return; + } + + var dynamicSqlColumnOffset = GetDynamicSqlColumnOffset(node); + + var (lastToken, column) = GetLastTokenAndColumn(node); + AddViolation(Name, Text, GetLineNumber(lastToken), column + dynamicSqlColumnOffset); + } + + private static (TSqlParserToken, int) GetLastTokenAndColumn(TSqlStatement node) + { + var lastToken = node.ScriptTokenStream[node.LastTokenIndex]; + var tabsOnLine = ColumnNumberCalculator.CountTabsBeforeToken(lastToken.Line, node.LastTokenIndex, node.ScriptTokenStream); + var column = ColumnNumberCalculator.GetColumnNumberAfterToken(tabsOnLine, lastToken); + + return (lastToken, column); + } + + private static bool EndsWithSemicolon(TSqlFragment node) + { + return node.ScriptTokenStream[node.LastTokenIndex].TokenType == TSqlTokenType.Semicolon + || node.ScriptTokenStream[node.LastTokenIndex + 1].TokenType == TSqlTokenType.Semicolon; + } +} diff --git a/SQLLinter/Infrastructure/Rules/TempTableDropRule.cs b/SQLLinter/Infrastructure/Rules/TempTableDropRule.cs new file mode 100644 index 0000000..a61d1df --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/TempTableDropRule.cs @@ -0,0 +1,18 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class TempTableDropRule : BaseRuleVisitor +{ + public override string Text => "Нежелательно использовать DROP временных таблиц: {0}"; + + public override void Visit(DropTableStatement node) + { + var check = node.Objects.Where(o => o.BaseIdentifier.Value.StartsWith("#")); + if (check.Any()) + { + AddViolation(node, string.Join(", ", check)); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/TempTableModificationRule.cs b/SQLLinter/Infrastructure/Rules/TempTableModificationRule.cs new file mode 100644 index 0000000..36b14bd --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/TempTableModificationRule.cs @@ -0,0 +1,34 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class TempTableModificationRule : BaseRuleVisitor +{ + public override string Text => "Избегать ALTER/UPDATE/DELETE для временных таблиц: {0}"; + + public override void Visit(UpdateStatement node) + { + if (node.UpdateSpecification.Target is NamedTableReference tbl && tbl.SchemaObject.BaseIdentifier.Value.StartsWith("#")) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(tbl.SchemaObject)); + } + } + + public override void Visit(DeleteStatement node) + { + if (node.DeleteSpecification.Target is NamedTableReference tbl && tbl.SchemaObject.BaseIdentifier.Value.StartsWith("#")) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(tbl.SchemaObject)); + } + } + + public override void Visit(AlterTableStatement node) + { + if (node.SchemaObjectName.BaseIdentifier.Value.StartsWith("#")) + { + AddViolation(node, SQLHelpers.ObjectGetFullName(node.SchemaObjectName)); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/TopWithoutOrderByRule.cs b/SQLLinter/Infrastructure/Rules/TopWithoutOrderByRule.cs new file mode 100644 index 0000000..b726755 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/TopWithoutOrderByRule.cs @@ -0,0 +1,18 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class TopWithoutOrderByRule : BaseRuleVisitor +{ + public override string Text => "TOP без ORDER BY может дать непредсказуемый результат"; + + public override void Visit(QuerySpecification node) + { + if (node.TopRowFilter != null && node.OrderByClause == null) + { + AddViolation(node.TopRowFilter); + } + base.Visit(node); + } +} \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Rules/UnicodeStringRule.cs b/SQLLinter/Infrastructure/Rules/UnicodeStringRule.cs new file mode 100644 index 0000000..80d5d88 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/UnicodeStringRule.cs @@ -0,0 +1,28 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; + +namespace SQLLinter.Infrastructure.Rules; + +public class UnicodeStringRule : BaseRuleVisitor, IRule +{ + public override string Text => "Использование символов Юникода в строке, отличной от Юникода"; + + public override void Visit(StringLiteral node) + { + if (node.IsNational) + { + return; + } + + if (!IsAscii(node.Value)) + { + AddViolation(Name, Text, node.StartLine, node.StartColumn); + } + } + + private static bool IsAscii(string part) + { + return SQLHelpers.IsValidForEncoding(part, "windows-1251"); + } +} diff --git a/SQLLinter/Infrastructure/Rules/UnionRule.cs b/SQLLinter/Infrastructure/Rules/UnionRule.cs new file mode 100644 index 0000000..201a817 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/UnionRule.cs @@ -0,0 +1,17 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class UnionRule : BaseRuleVisitor +{ + public override string Text => "Избегать UNION, использовать UNION ALL."; + + public override void Visit(BinaryQueryExpression node) + { + if (node.BinaryQueryExpressionType == BinaryQueryExpressionType.Union && !node.All) + { + AddViolation(node); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/UpdateWhereRule.cs b/SQLLinter/Infrastructure/Rules/UpdateWhereRule.cs new file mode 100644 index 0000000..6d65e69 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/UpdateWhereRule.cs @@ -0,0 +1,28 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class UpdateWhereRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Ожидается выражение WHERE для оператора UPDATE: {0}"; + + + public override void Visit(UpdateSpecification node) + { + if (node.WhereClause != null) + { + return; + } + + + string name = string.Join("", node.Target.ScriptTokenStream + .Skip(node.Target.FirstTokenIndex) + .Take(node.Target.LastTokenIndex - node.Target.FirstTokenIndex + 1) + .Select(t => t.Text) + ); + + AddViolation(node, name); + } +} diff --git a/SQLLinter/Infrastructure/Rules/UpperLowerRule.cs b/SQLLinter/Infrastructure/Rules/UpperLowerRule.cs new file mode 100644 index 0000000..c4d1adb --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/UpperLowerRule.cs @@ -0,0 +1,64 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class UpperLowerRule : BaseRuleVisitor, IRule +{ + + public override string Text => "Использование функций UPPER или LOWER при выполнении сравнений в операторах SELECT не требуется при запуске базы данных в режиме без учета регистра."; + + public override void Visit(SelectStatement node) + { + var visitor = new ChildQueryComparisonVisitor(); + node.Accept(visitor); + if (visitor.QueryExpressionUpperLowerFunctionFound) + { + AddViolation(node); + } + } + + public class ChildQueryComparisonVisitor : TSqlFragmentVisitor + { + public bool QueryExpressionUpperLowerFunctionFound { get; private set; } + + public override void Visit(QueryExpression node) + { + var visitor = new ChildBooleanComparisonVisitor(); + node.Accept(visitor); + if (visitor.UpperLowerFunctionCallInComparison) + { + QueryExpressionUpperLowerFunctionFound = true; + } + } + } + + public class ChildBooleanComparisonVisitor : TSqlFragmentVisitor + { + public bool UpperLowerFunctionCallInComparison { get; private set; } + + public override void Visit(BooleanComparisonExpression node) + { + var visitor = new ChildFunctionCallVisitor(); + node.Accept(visitor); + if (visitor.UpperLowerFound) + { + UpperLowerFunctionCallInComparison = true; + } + } + } + + public class ChildFunctionCallVisitor : TSqlFragmentVisitor + { + public bool UpperLowerFound { get; private set; } + + public override void Visit(FunctionCall node) + { + if (node.FunctionName.Value.Equals("UPPER", StringComparison.OrdinalIgnoreCase) || + node.FunctionName.Value.Equals("LOWER", StringComparison.OrdinalIgnoreCase)) + { + UpperLowerFound = true; + } + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/UserFunctionJoinRule.cs b/SQLLinter/Infrastructure/Rules/UserFunctionJoinRule.cs new file mode 100644 index 0000000..244a679 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/UserFunctionJoinRule.cs @@ -0,0 +1,158 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; +using SQLLinter.Common.Helpers; +using SQLLinter.Core; + +namespace SQLLinter.Infrastructure.Rules; + +public class UserFunctionJoinRule : BaseRuleVisitor +{ + public override string Text => "Запрещено использование пользовательских функций внутри запросов: {0}"; + + private bool _inQueryContext; + + // Входим в контекст запроса + public override void Visit(QuerySpecification node) + { + _inQueryContext = true; + + // Проверяем FROM + if (node.FromClause != null) + { + var tables = node.FromClause.TableReferences; + + // Разрешаем ровно одну табличную функцию + if (!(tables.Count == 1 && tables[0] is SchemaObjectFunctionTableReference ft && IsUserFunction(ft.SchemaObject?.BaseIdentifier?.Value))) + { + foreach (var tr in tables) + CheckTableReference(tr); + } + } + + base.Visit(node); + _inQueryContext = false; + } + + // Скалярные функции + public override void Visit(FunctionCall node) + { + if (_inQueryContext && IsUserFunction(node.FunctionName.Value)) + { + AddViolationFunction(node); + } + + base.Visit(node); + } + + // Табличные функции + public override void Visit(SchemaObjectFunctionTableReference node) + { + // Проверка делается в QuerySpecification - здесь ничего не делаем + base.Visit(node); + } + + // Подзапросы + public override void Visit(ScalarSubquery node) + { + node.QueryExpression.Accept(this); + base.Visit(node); + } + + public override void Visit(QueryDerivedTable node) + { + node.QueryExpression.Accept(this); + base.Visit(node); + } + + public override void Visit(QualifiedJoin node) + { + if (node.SearchCondition != null) + node.SearchCondition.Accept(this); + + if (node.FirstTableReference != null) + CheckTableReference(node.FirstTableReference); + if (node.SecondTableReference != null) + CheckTableReference(node.SecondTableReference); + + base.Visit(node); + } + + public override void Visit(UnqualifiedJoin node) + { + // Проверяем APPLY + if (node.UnqualifiedJoinType == UnqualifiedJoinType.CrossApply || + node.UnqualifiedJoinType == UnqualifiedJoinType.OuterApply) + { + if (node.SecondTableReference is SchemaObjectFunctionTableReference ft && + IsUserFunction(ft.SchemaObject?.BaseIdentifier?.Value)) + { + AddViolation(ft, SQLHelpers.ObjectGetFullName(ft.SchemaObject)); + } + } + + // Обходим обе стороны + if (node.FirstTableReference != null) + CheckTableReference(node.FirstTableReference); + if (node.SecondTableReference != null) + CheckTableReference(node.SecondTableReference); + + base.Visit(node); + } + + + + + // ------------------------ + // Вспомогательные методы + // ------------------------ + + private void AddViolationFunction(FunctionCall func) + { + string ident = ""; + if (func.CallTarget is MultiPartIdentifierCallTarget callTarget) + { + ident = SQLHelpers.ObjectGetFullName(callTarget.MultiPartIdentifier.Identifiers); + if (!string.IsNullOrWhiteSpace(ident)) ident += "."; + } + + ident += "[" + func.FunctionName.Value + "]"; + AddViolation(func, ident); + } + + private void CheckTableReference(TableReference tr) + { + switch (tr) + { + case SchemaObjectFunctionTableReference ft: + if (IsUserFunction(ft.SchemaObject?.BaseIdentifier?.Value)) + AddViolation(ft, SQLHelpers.ObjectGetFullName(ft.SchemaObject)); + break; + + case QueryDerivedTable qdt: + qdt.QueryExpression.Accept(this); + break; + + case QualifiedJoin qj: + if (qj.FirstTableReference != null) + CheckTableReference(qj.FirstTableReference); + if (qj.SecondTableReference != null) + CheckTableReference(qj.SecondTableReference); + if (qj.SearchCondition != null) + qj.SearchCondition.Accept(this); + break; + + case UnqualifiedJoin aj: + if (aj.FirstTableReference != null) + CheckTableReference(aj.FirstTableReference); + if (aj.SecondTableReference != null) + CheckTableReference(aj.SecondTableReference); + break; + } + } + + private bool IsUserFunction(string? functionName) + { + return !string.IsNullOrEmpty(functionName) && + !Constants.SystemFunctions.Contains(functionName); + } +} diff --git a/SQLLinter/Infrastructure/Rules/WhereInSelectRule.cs b/SQLLinter/Infrastructure/Rules/WhereInSelectRule.cs new file mode 100644 index 0000000..0efdbe4 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/WhereInSelectRule.cs @@ -0,0 +1,17 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class WhereInSelectRule : BaseRuleVisitor +{ + public override string Text => "Запрещено IN (SELECT ...), используйте EXISTS/NOT EXISTS."; + + public override void Visit(InPredicate node) + { + if (node.Subquery != null) + { + AddViolation(node); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/XmlJsonParsingRule.cs b/SQLLinter/Infrastructure/Rules/XmlJsonParsingRule.cs new file mode 100644 index 0000000..7f9bc75 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/XmlJsonParsingRule.cs @@ -0,0 +1,18 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SQLLinter.Common; + +namespace SQLLinter.Infrastructure.Rules; + +public class XmlJsonParsingRule : BaseRuleVisitor +{ + public override string Text => "Запрещён парсинг XML/JSON."; + + public override void Visit(FunctionCall node) + { + var fn = node.FunctionName.Value.ToUpperInvariant(); + if (fn.Contains("OPENXML") || fn.Contains("OPENJSON") || fn.Contains("NODES")) + { + AddViolation(node); + } + } +} diff --git a/SQLLinter/Linter.cs b/SQLLinter/Linter.cs new file mode 100644 index 0000000..0d5cf9f --- /dev/null +++ b/SQLLinter/Linter.cs @@ -0,0 +1,62 @@ +using SQLLinter.Common; +using SQLLinter.Common.Helpers; +using SQLLinter.Core.Interfaces; +using SQLLinter.Infrastructure.Parser; +using SQLLinter.Infrastructure.Plugins; + +namespace SQLLinter; + +public class Linter +{ + private readonly IReporter _reporter; + private readonly IConfig _config; + + private IPluginHandler _pluginHandler; + private ISqlFileProcessor _fileProcessor; + + public Linter(IConfig config, IReporter reporter) + { + 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); + + _fileProcessor = new SqlFileProcessor(ruleVisitor, _pluginHandler, reporter); + + _reporter.Report($"SQL Linter загружен..."); + } + + public void Run(string filePath) + { + this.Run([filePath]); + } + + public void Run(List filePaths) + { + _reporter.Report($"Запуск SQL Linter..."); + + List files = FileHelpers.FindFilesWithMask(filePaths); + files.ForEach(file => _reporter.Report($@"Найден файл ""{file}""")); + + _fileProcessor.ProcessList(filePaths); + } + + public void Run(string fileName, Stream fileReader) + { + Run(new Dictionary { [fileName] = fileReader }); + } + + public void Run(Dictionary files) + { + _reporter.Report($"Запуск SQL Linter..."); + + _fileProcessor.ProcessList(files); + } +} diff --git a/SQLLinter/SQLLinter.csproj b/SQLLinter/SQLLinter.csproj new file mode 100644 index 0000000..6be11c1 --- /dev/null +++ b/SQLLinter/SQLLinter.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + true + SQLLinter + 1.0.0 + FrigaT + FrigaT + SQLLinter + Линтер с правилами проверки MS SQL кода + Copyright © 2025 FrigaT + https://git.frigat.duckdns.org/FrigaT/SQLLint + git + https://git.frigat.duckdns.org/FrigaT/SQLLint + MIT + + + + + + + diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..91ba526 --- /dev/null +++ b/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file