Добавьте файлы проекта.

This commit is contained in:
2025-12-07 08:52:05 +03:00
parent 95344cd7a7
commit 226b6b6b21
118 changed files with 5249 additions and 0 deletions

4
SQLLint.slnx Normal file
View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="SQLLinter.CLI/SQLLinter.CLI.csproj" />
<Project Path="SQLLinter/SQLLinter.csproj" />
</Solution>

58
SQLLinter.CLI/Program.cs Normal file
View File

@@ -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");
}
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Version>1.0.0</Version>
<Authors>FrigaT</Authors>
<Company>FrigaT</Company>
<Product>SQLLinter.CLI</Product>
<Description>cli клиент для проверки MS SQL кода</Description>
<Copyright>Copyright © 2025 FrigaT</Copyright>
<RepositoryUrl>https://git.frigat.duckdns.org/FrigaT/SQLLint</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://git.frigat.duckdns.org/FrigaT/SQLLint</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ArgumentsToolkit" Version="0.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SQLLinter\SQLLinter.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,72 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
namespace SQLLinter.Common;
public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
{
protected readonly List<Violation> _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<string> fileLines, IRuleViolation ruleViolation, FileLineActions actions)
{
}
public virtual IEnumerable<Violation> 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);
}
}

View File

@@ -0,0 +1,53 @@
namespace SQLLinter.Common.Helpers;
public static class FileHelpers
{
public static List<string> FindFilesWithMask(List<string> paths)
{
return paths.SelectMany(path =>
{
var fullPath = Path.GetFullPath(path);
var directory = Path.GetDirectoryName(fullPath);
if (directory == null) return Enumerable.Empty<string>();
string pattern = Path.GetFileName(fullPath);
// Если маска есть в директории
if (directory.Contains("*") || directory.Contains("?"))
{
var root = Path.GetPathRoot(directory);
if (root == null) return Enumerable.Empty<string>();
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<string>();
}
}).ToList();
}
public static IEnumerable<string> ExpandDirectories(string root, string relative)
{
string[] parts = relative.Split(Path.DirectorySeparatorChar);
IEnumerable<string> 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<string>())
: dirs.Select(d => Path.Combine(d, part));
}
return dirs.Where(Directory.Exists);
}
}

View File

@@ -0,0 +1,98 @@
namespace SQLLinter.Common;
public class FileLineActions
{
private readonly List<string> FileLines;
private readonly List<IRuleViolation> RuleViolations;
public FileLineActions(List<IRuleViolation> ruleViolations, List<string> 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<string> lines)
{
FileLines.InsertRange(index, lines);
foreach (IRuleViolation item in RuleViolations.Where((IRuleViolation x) => x.Line > index))
{
item.Line += lines.Count;
}
}
public void RemoveAll(Func<string, bool> 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;
}
}

View File

@@ -0,0 +1,94 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using System.Text.RegularExpressions;
namespace SQLLinter.Common.Helpers;
public static class FixHelpers
{
public class FindViolatingNodeVisitor<T> : TSqlFragmentVisitor where T : TSqlFragment
{
private readonly Func<T, bool> Where;
public List<T> Nodes = new List<T>();
public FindViolatingNodeVisitor(Func<T, bool> 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<TFind, TReturn>(List<string> fileLines, IRuleViolation ruleViolation, Func<TFind, TReturn> getFragment) where TFind : TSqlFragment where TReturn : TSqlFragment
{
TFind val = FindNodes<TFind>(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<T> FindNodes<T>(List<string> fileLines, Func<T, bool> where = null) where T : TSqlFragment
{
using StringReader input = new StringReader(string.Join("\n", fileLines));
IList<ParseError> 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<T> findViolatingNodeVisitor = new FindViolatingNodeVisitor<T>(where);
tSqlFragment.Accept(findViolatingNodeVisitor);
return findViolatingNodeVisitor.Nodes;
}
public static List<T> FindNodes<T>(TSqlFragment statement, Func<T, bool> where = null) where T : TSqlFragment
{
FindViolatingNodeVisitor<T> findViolatingNodeVisitor = new FindViolatingNodeVisitor<T>(where);
statement.Accept(findViolatingNodeVisitor);
return findViolatingNodeVisitor.Nodes;
}
public static T FindViolatingNode<T>(List<string> fileLines, IRuleViolation ruleViolation) where T : TSqlFragment
{
return FindViolatingNode(fileLines, ruleViolation, (T x) => x).Item1;
}
public static string GetIndent(List<string> fileLines, IRuleViolation ruleViolation)
{
return GetIndent(fileLines[ruleViolation.Line - 1]);
}
public static string GetIndent(List<string> 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;
}
}

View File

@@ -0,0 +1,6 @@
namespace SQLLinter.Common;
public interface IBaseReporter
{
void Report(string message);
}

View File

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

17
SQLLinter/Common/IRule.cs Normal file
View File

@@ -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<Violation> Analyze(TSqlFragment fragment);
}

View File

@@ -0,0 +1,10 @@
namespace SQLLinter.Common;
public interface IRuleException
{
int StartLine { get; }
int EndLine { get; }
string RuleName { get; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,9 @@
namespace SQLLinter.Common;
public enum RuleViolationSeverity
{
Off,
Info,
Warning,
Critical,
}

View File

@@ -0,0 +1,59 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using System.Text;
namespace SQLLinter.Common.Helpers;
public static class SQLHelpers
{
/// <summary>
/// Проверяет, что строка может быть закодирована в указанной SQL кодировке (collation).
/// </summary>
/// <param name="input">Строка для проверки</param>
/// <param name="sqlEncodingName">Имя кодировки, например "windows-1251" или "iso-8859-1"</param>
/// <returns>true, если строка полностью совместима</returns>
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<Identifier> identifiers)
{
return string.Join(".", identifiers.Select(i => "[" + i.Value + "]"));
}
}

View File

@@ -0,0 +1,4 @@
namespace SQLLinter.Common
{
public record Violation(string RuleName, string Message, int Line, int Column);
}

310
SQLLinter/Core/Constants.cs Normal file
View File

@@ -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<string> TSqlKeywords = new HashSet<string>(_TSqlKeywords);
public static readonly HashSet<string> TSqlDataTypes = new HashSet<string>(_TSqlDataTypes);
public static int TabWidth => 4;
public static int DefaultCompatabilityLevel => 120;
public static int MaxLineWidthForRegexEval => 300;
public static HashSet<string> 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"
};
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,10 @@
using System.Reflection;
namespace SQLLinter.Core.Interfaces;
public interface IAssemblyWrapper
{
Assembly LoadFrom(string path);
Type[] GetExportedTypes(Assembly assembly);
}

View File

@@ -0,0 +1,24 @@
using SQLLinter.Common;
namespace SQLLinter.Core.Interfaces
{
public interface IConfig
{
/// <summary>
/// Уровень совместимости SQL Server
/// </summary>
int CompatibilityLevel { get; set; }
/// <summary>
/// Включенные правила.
/// Key - Название правила.
/// Value - Уровень предупреждения.
/// </summary>
Dictionary<string, RuleViolationSeverity> Rules { get; set; }
/// <summary>
/// Список сторонних плагинов.
/// </summary>
List<string> Plugins { get; set; }
}
}

View File

@@ -0,0 +1,8 @@
using SQLLinter.Common;
namespace SQLLinter.Core.Interfaces;
public interface IExtendedRuleException : IRuleException
{
void SetEndLine(int endLine);
}

View File

@@ -0,0 +1,10 @@
namespace SQLLinter.Core.Interfaces;
public interface IFileSystemWrapper
{
bool FileExists(string path);
bool PathIsValidForLint(string path);
string CombinePath(params string[] paths);
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace SQLLinter.Core.Interfaces;
public interface IFileversionWrapper
{
string GetVersion(Assembly assembly);
}

View File

@@ -0,0 +1,6 @@
namespace SQLLinter.Core.Interfaces;
public interface IGlobPatternMatcher
{
IEnumerable<string> GetResultsInFullPath(string path);
}

View File

@@ -0,0 +1,4 @@
namespace SQLLinter.Core.Interfaces;
public interface IOverride
{ }

View File

@@ -0,0 +1,9 @@
using SQLLinter.Common;
namespace SQLLinter.Core.Interfaces;
public interface IPluginHandler
{
IList<IRule> Rules { get; }
IDictionary<string, IRule> RuleWithNames { get; }
}

View File

@@ -0,0 +1,19 @@
namespace SQLLinter.Core.Interfaces.Config.Contracts;
public interface IRequestHandler<in TRequest, out TResponse> where TRequest : IRequest<TResponse>
{
TResponse Handle(TRequest request);
}
public interface IRequestHandler<in TRequest> where TRequest : IRequest
{
void Handle(TRequest message);
}
public interface IRequest
{
}
public interface IRequest<out TResponse>
{
}

View File

@@ -0,0 +1,6 @@
namespace SQLLinter.Core.Interfaces;
public interface IRuleExceptionFinder
{
IEnumerable<IExtendedRuleException> GetIgnoredRuleList(Stream fileStream);
}

View File

@@ -0,0 +1,8 @@
using SQLLinter.Common;
namespace SQLLinter.Core.Interfaces;
public interface IRuleVisitor
{
void VisitRules(string path, IEnumerable<IRuleException> igoredRules, Stream sqlFileStream);
}

View File

@@ -0,0 +1,11 @@
namespace SQLLinter.Core.Interfaces;
public interface ISqlFileProcessor
{
int FileCount { get; }
void ProcessList(List<string> filePaths);
void ProcessList(Dictionary<string, Stream> files);
void ProcessPath(string path);
}

View File

@@ -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<string, RuleViolationSeverity> Rules { get; set; } = new();
public List<string> Plugins { get; set; } = new();
}
}

View File

@@ -0,0 +1,10 @@
namespace SQLLinter.Infrastructure.Configuration
{
public class EnvironmentWrapper
{
public string GetEnvironmentVariable(string name)
{
return Environment.GetEnvironmentVariable(name);
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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<IOverride> GetOverrideList(Stream fileStream)
{
var overrideList = new List<IOverride>();
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;
}
}

View File

@@ -0,0 +1,12 @@
namespace SQLLinter.Infrastructure.Configuration.Overrides
{
public class OverrideTypeMap
{
public static readonly Dictionary<string, Type> List = new Dictionary<string, Type>
{
{ "compatibility-level", typeof(OverrideCompatabilityLevel) },
// Deprecate usage of misspelled "compatability-level".
{ "compatability-level", typeof(OverrideCompatabilityLevel) }
};
}
}

View File

@@ -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<ParseError> errors, IEnumerable<IOverride> overrides = null);
}
}

View File

@@ -0,0 +1,7 @@
namespace SQLLinter.Infrastructure.Interfaces
{
public interface ISqlStreamReaderBuilder
{
StreamReader CreateReader(Stream sqlFileStream);
}
}

View File

@@ -0,0 +1,7 @@
namespace SQLLinter.Infrastructure.Interfaces
{
public interface IViolationFixer
{
void Fix();
}
}

View File

@@ -0,0 +1,152 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
namespace SQLLinter.Infrastructure.Parser
{
public class DynamicSQLParser : TSqlFragmentVisitor
{
private readonly Action<string, int, int> callback;
private string executableSql = string.Empty;
private Dictionary<string, VariableVisitor.VariableRef> VariableValues = new();
private int DynamicSQLStartingLine { get; set; }
private int DynamicSQLStartingColumn { get; set; }
public DynamicSQLParser(Action<string, int, int> 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<string, VariableRef> 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; }
}
}
}

View File

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

View File

@@ -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<ParseError> errors, IEnumerable<IOverride> 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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,28 @@
using SQLLinter.Common;
using System.Reflection;
namespace SQLLinter.Infrastructure.Parser
{
public static class RuleVisitorFriendlyNameTypeMap
{
public static List<Type> DefaultRuleTypes
{
get
{
Assembly assembly = Assembly.GetExecutingAssembly();
return GetRuleTypes(assembly);
}
}
public static List<Type> GetRuleTypes(Assembly assembly)
{
List<Type> sqlRuleTypes = assembly
.GetTypes()
.Where(t => typeof(IRule).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract) // исключаем абстрактные
.ToList();
return sqlRuleTypes;
}
}
}

View File

@@ -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<string> filePaths)
{
foreach (var path in filePaths)
{
ProcessFile(path);
}
}
public void ProcessPath(string path)
{
ProcessFile(path);
}
private void ProcessFile(string filePath)
{
var fileStream = GetFileContents(filePath);
HandleProcessing(filePath, fileStream);
}
public void ProcessList(Dictionary<string, Stream> files)
{
foreach (var file in files)
{
HandleProcessing(file.Key, file.Value);
}
}
private bool IsWholeFileIgnored(string filePath, IEnumerable<IExtendedRuleException> ignoredRules)
{
var ignoredRulesEnum = ignoredRules.ToArray();
if (!ignoredRulesEnum.Any())
{
return false;
}
var lineOneRuleIgnores = ignoredRulesEnum.OfType<GlobalRuleException>().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<IRuleException> ignoredRules, string filePath)
{
ruleVisitor.VisitRules(filePath, ignoredRules, fileStream);
}
private Stream GetFileContents(string filePath)
{
return File.OpenRead(filePath);
}
}

View File

@@ -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<IRuleException> 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<IOverride> 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<string>
{
"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<ParseError> errors, IEnumerable<IRuleException> ignoredRules)
{
var updatedExitCode = false;
var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray();
foreach (var error in errors)
{
var globalRulesOnLine = ruleExceptions.OfType<GlobalRuleException>().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;
}
}
}
}

View File

@@ -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(@"\$\((?<placeholder>[^)]+)\)", 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();
}
}

View File

@@ -0,0 +1,50 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Interfaces;
namespace SQLLinter.Infrastructure.Parser;
public class ViolationFixer : IViolationFixer
{
private readonly Dictionary<string, IRule> Rules;
private readonly IList<IRuleViolation> Violations;
public ViolationFixer(
Dictionary<string, IRule> rules,
IList<IRuleViolation> 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<string>(fileLines);
//Rules[violation.RuleName].FixViolation(lines, violation, fileLineActions);
}
}
File.WriteAllLines(file.Key, fileLines);
}
}
}

View File

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

View File

@@ -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<Type> _ruleTypes = new();
private Dictionary<string, IRule> _rules = new();
public IList<IRule> Rules => _rules.Values.ToList();
public IDictionary<string, IRule> 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);
}
}
}
}

View File

@@ -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;
}
}
}

View File

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

View File

@@ -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 "<p><em>Нет нарушений</em></p>";
}
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key);
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"ru\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<title>Отчёт по SQLпроверкам</title>");
sb.AppendLine("<style>");
sb.AppendLine("body { font-family: 'Segoe UI', Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; color: #000; }");
sb.AppendLine("h1 { padding: 20px; margin: 0; background-color: #0078d4; color: white; }");
sb.AppendLine("h2 { margin-top: 20px; color: #333; }");
sb.AppendLine("h3 { margin-top: 15px; color: #555; }");
sb.AppendLine("table { border-collapse: collapse; width: 100%; margin: 20px 0; box-shadow: 0 2px 6px rgba(0,0,0,0.1); background-color: white; border-radius: 4px; overflow: hidden; }");
sb.AppendLine("th, td { border: 1px solid #e0e0e0; padding: 4px 8px; text-align: left; line-height: 1.2; }");
sb.AppendLine("th { background-color: #fafafa; font-weight: 600; }");
sb.AppendLine("td.line, td.column { width: 60px; text-align: center; }");
sb.AppendLine("td.rule { width: 300px; text-align: left; }");
sb.AppendLine("td.index { width: 40px; text-align: center; }");
sb.AppendLine(".critical { border-left: 4px solid #d13438; padding: 10px; margin-bottom: 20px; background-color: #fde7e9; }");
sb.AppendLine(".warning { border-left: 4px solid #ffaa44; padding: 10px; margin-bottom: 20px; background-color: #fff4ce; }");
sb.AppendLine(".info { border-left: 4px solid #0078d4; padding: 10px; margin-bottom: 20px; background-color: #deecf9; }");
sb.AppendLine(".tabs { position: fixed; bottom: 0; left: 0; right: 0; background-color: #ffffff; border-top: 1px solid #ccc; padding: 10px; display: flex; overflow-x: auto; scrollbar-width: thin; justify-content: flex-start; box-shadow: 0 -2px 6px rgba(0,0,0,0.1); }");
sb.AppendLine(".tab { margin-right: 10px; padding: 8px 16px; border-radius: 4px; background-color: #f3f2f1; cursor: pointer; transition: background-color 0.2s; }");
sb.AppendLine(".tab:hover { background-color: #e1dfdd; }");
sb.AppendLine(".tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(".file-report { display: none; padding: 20px 0; }");
sb.AppendLine(".file-report.active { display: block; }");
// Тёмная тема
sb.AppendLine("@media (prefers-color-scheme: dark) {");
sb.AppendLine(" body { background-color: #1e1e1e; color: #ddd; }");
sb.AppendLine(" h1 { background-color: #005a9e; }");
sb.AppendLine(" h2, h3 { color: #ddd; }");
sb.AppendLine(" table { background-color: #2d2d2d; box-shadow: none; }");
sb.AppendLine(" th { background-color: #3c3c3c; color: #fff; }");
sb.AppendLine(" td { border: 1px solid #444; }");
sb.AppendLine(" .tabs { background-color: #2d2d2d; border-top: 1px solid #444; }");
sb.AppendLine(" .tab { background-color: #3c3c3c; color: #ddd; }");
sb.AppendLine(" .tab:hover { background-color: #555; }");
sb.AppendLine(" .tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(" .critical { background-color: #4d1f1f; border-left-color: #d13438; }");
sb.AppendLine(" .warning { background-color: #4d3b1f; border-left-color: #ffaa44; }");
sb.AppendLine(" .info { background-color: #1f3b4d; border-left-color: #0078d4; }");
sb.AppendLine("}");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
//sb.AppendLine("<h1>Отчёт по SQLпроверкам</h1>");
int fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
string divId = $"file_{fileIndex}";
sb.AppendLine($"<div id=\"{divId}\" class=\"file-report{(fileIndex == 0 ? " active" : "")}\">");
sb.AppendLine($"<h1>Файл: {fileGroup.Key}</h1>");
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($"<div class=\"{severityClass}\">");
sb.AppendLine($"<h3>{severityGroup.Key}</h3>");
sb.AppendLine("<table>");
sb.AppendLine("<thead>");
sb.AppendLine("<tr><th>#</th><th>Строка</th><th>Колонка</th><th>Правило</th><th>Описание</th></tr>");
sb.AppendLine("</thead>");
sb.AppendLine("<tbody>");
int rowIndex = 1;
foreach (var v in severityGroup
.OrderBy(x => x.Line)
.ThenBy(x => x.Column))
{
sb.AppendLine($"<tr><td class=\"index\">{rowIndex}</td><td class=\"line\">{v.Line}</td><td class=\"column\">{v.Column}</td><td class=\"rule\">{v.RuleName}</td><td>{v.Text}</td></tr>");
rowIndex++;
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
fileIndex++;
}
// Табы снизу
sb.AppendLine("<div class=\"tabs\">");
fileIndex = 0;
foreach (var fileGroup in groupedByFile)
{
sb.AppendLine($"<div class=\"tab{(fileIndex == 0 ? " active" : "")}\" onclick=\"showReport('file_{fileIndex}', this)\">{fileGroup.Key}</div>");
fileIndex++;
}
sb.AppendLine("</div>");
// JS для переключения
sb.AppendLine("<script>");
sb.AppendLine("function showReport(id, tab) {");
sb.AppendLine(" document.querySelectorAll('.file-report').forEach(el => el.classList.remove('active'));");
sb.AppendLine(" document.getElementById(id).classList.add('active');");
sb.AppendLine(" document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));");
sb.AppendLine(" tab.classList.add('active');");
sb.AppendLine(" window.scrollTo({ top: 0, behavior: 'smooth' });");
sb.AppendLine("}");
sb.AppendLine("document.querySelector('.tabs').addEventListener('wheel', function(e) {");
sb.AppendLine(" e.preventDefault();");
sb.AppendLine(" this.scrollLeft += e.deltaY;");
sb.AppendLine("});");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
}

View File

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

View File

@@ -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<string> _log = new();
public int? FixedCount { get; set; }
private readonly ConcurrentBag<IRuleViolation> ruleViolations = new();
public List<IRuleViolation> Violations => ruleViolations.ToList();
public virtual void Report(string message)
{
_log.Add(message);
}
public List<string> 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));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
/// <summary>
/// Имена переменных будут использовать общий регистр
/// </summary>
public class CaseSensitiveVariablesRule : BaseRuleVisitor, IRule
{
private readonly List<string> variableNames;
public CaseSensitiveVariablesRule()
{
this.variableNames = new List<string>();
}
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);
}
}

View File

@@ -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<NullableConstraintDefinition>()
.Any();
if (!hasNullabilityConstraint)
{
AddViolation(node,
_currentTable ?? "<unknown>",
node.ColumnIdentifier?.Value ?? "<unknown>");
}
_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<NullableConstraintDefinition>()
.Any();
if (!hasNullabilityConstraint)
{
AddViolation(node,
_currentTable ?? "<unknown>",
node.ColumnIdentifier?.Value ?? "<unknown>");
}
}
}

View File

@@ -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;
}
/// <summary>
/// Returns -1 if can't be found
/// </summary>
/// <param name="line"></param>
/// <param name="column"></param>
/// <returns></returns>
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<TSqlParserToken> 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);
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
using SQLLinter.Common.Helpers;
using System.Text.RegularExpressions;
namespace SQLLinter.Infrastructure.Rules;
/// <summary>
/// Ожидается наличие BEGIN - END в блоке IF
/// </summary>
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<string> fileLines, IRuleViolation ruleViolation, FileLineActions actions)
{
var ifNode = FixHelpers.FindViolatingNode<IfStatement>(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<string> fileLines, IRuleViolation ruleViolation)
{
return FixHelpers.FindViolatingNode<IfStatement, TSqlStatement>(
fileLines, ruleViolation, x => x.ElseStatement);
}
}
}

View File

@@ -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(<PK>)";
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<string> fileLines, IRuleViolation ruleViolation, FileLineActions actions)
{
var node = FixHelpers.FindViolatingNode<FunctionCall>(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;
}
}
}

View File

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

View File

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

View File

@@ -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<TrackedTransaction> TransactionLists { get; } = new List<TrackedTransaction>();
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<string> DatabasesUpdated { get; } = new HashSet<string>();
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<string> DatabasesUpdated { get; } = new HashSet<string>();
public override void Visit(NamedTableReference node)
{
if (node.SchemaObject.DatabaseIdentifier != null)
{
DatabasesUpdated.Add(node.SchemaObject.DatabaseIdentifier.Value);
}
}
}
}

View File

@@ -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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<int, HashSet<string>> _activeAliases = new();
private int _dmlDepth = 0;
private bool _insideApply = false;
private void EnterDmlScope(bool inherit)
{
_dmlDepth++;
if (_dmlDepth == 1 || !inherit)
{
_activeAliases[_dmlDepth] = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
else
{
// дочерний скоуп видит алиасы родителя (для глобальной уникальности в рамках одного DML)
var parent = _activeAliases[_dmlDepth - 1];
_activeAliases[_dmlDepth] = new HashSet<string>(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);
}
}

View File

@@ -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<string> fileLines, IRuleViolation ruleViolation, FileLineActions actions)
{
if (ruleViolation.Line - 1 == fileLines.Count)
{
actions.RemoveAt(ruleViolation.Line - 2);
}
else
{
actions.RemoveAt(ruleViolation.Line - 1);
}
}
}

View File

@@ -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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string> fileLines, IRuleViolation ruleViolation, FileLineActions actions)
{
var node = FixHelpers.FindNodes<TSqlStatement>(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<FromClause>(statement);
var whereClauses = FixHelpers.FindNodes<WhereClause>(statement);
if (fromClauses.Count == 1 && whereClauses.Count <= 1)
{
var fromClause = fromClauses[0];
var whereClause = whereClauses.FirstOrDefault();
var tableName = FixHelpers.FindNodes<SchemaObjectName>(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<BooleanComparisonExpression>(whereClause,
x => FixHelpers.FindNodes<Identifier>(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);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string> 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));
}
}

View File

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

View File

@@ -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<string> cteNames = new HashSet<string>();
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<string> CommonTableExpressionIdentifiers { get; } = new HashSet<string>();
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<TSqlFragment> childCallback;
public ChildTableAliasVisitor(Action<TSqlFragment> errorCallback, HashSet<string> cteNames)
{
CteNames = cteNames;
childCallback = errorCallback;
}
public HashSet<string> CteNames { get; }
public override void Visit(NamedTableReference node)
{
if (CteNames.Contains(node.SchemaObject.BaseIdentifier.Value))
{
return;
}
if (node.Alias == null)
{
childCallback(node);
}
}
}
}

View File

@@ -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;
}
}
}

View File

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

View File

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

View File

@@ -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<TSqlFragment> 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<TSqlFragment> childCallback;
private readonly bool isMultiClauseQuery;
public JoinQueryVisitor(Action<TSqlFragment> 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<TSqlFragment> childCallback;
private bool hasColumnReferenceParameter;
public FunctionVisitor(Action<TSqlFragment> 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ReturnPosition> 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);
}

View File

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

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

Some files were not shown because too many files have changed in this diff Show More