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

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

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

View File

@@ -0,0 +1,98 @@
using SQLLinter.Common;
using SQLLinter.Core;
using SQLLinter.Core.Interfaces;
using System.Text.RegularExpressions;
namespace SQLLinter.Infrastructure.Rules.RuleExceptions
{
public class RuleExceptionFinder : IRuleExceptionFinder
{
public static Regex RuleExceptionRegex = new Regex(@"(sqllinter-(?:dis|en)able)\s*(.*)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private readonly IDictionary<string, IRule> Rules;
public RuleExceptionFinder(IDictionary<string, IRule> rules)
{
Rules = rules;
}
public IEnumerable<IExtendedRuleException> GetIgnoredRuleList(Stream fileStream)
{
var ruleExceptionList = new List<IExtendedRuleException>();
TextReader reader = new StreamReader(fileStream);
var lineNumber = 0;
string line;
while ((line = reader.ReadLine()) != null)
{
lineNumber++;
if (line.Length > Constants.MaxLineWidthForRegexEval || !line.Contains("sqllinter-"))
{
continue;
}
var match = RuleExceptionRegex.Match(line);
if (!match.Success)
{
continue;
}
FindIgnoredRules(ruleExceptionList, lineNumber, match);
}
fileStream.Seek(0, SeekOrigin.Begin);
foreach (var ruleException in ruleExceptionList)
{
if (ruleException.EndLine == 0)
{
ruleException.SetEndLine(lineNumber);
}
}
return ruleExceptionList;
}
private void FindIgnoredRules(ICollection<IExtendedRuleException> ruleExceptionList, int lineNumber, Match match)
{
var action = match.Groups[1].Value;
var disableCommand = action.Equals("sqllinter-disable", StringComparison.OrdinalIgnoreCase);
var enableCommand = action.Equals("sqllinter-enable", StringComparison.OrdinalIgnoreCase);
var ruleExceptionDetails = match.Groups[2].Value.Split(' ').Select(p => p.Trim()).ToList();
var matchedFriendlyNames = ruleExceptionDetails.Intersect(Rules.Keys).ToList();
if (!matchedFriendlyNames.Any())
{
if (disableCommand)
{
var ruleException = new GlobalRuleException(lineNumber, 0);
ruleExceptionList.Add(ruleException);
}
if (enableCommand)
{
var ruleException = ruleExceptionList.OfType<GlobalRuleException>().FirstOrDefault(r => r.EndLine == 0);
ruleException?.SetEndLine(lineNumber);
}
}
foreach (var matchedFriendlyName in matchedFriendlyNames)
{
Rules.TryGetValue(matchedFriendlyName, out var matched);
if (disableCommand)
{
var ruleException = new RuleException(matched.GetType(), matchedFriendlyName, lineNumber, 0);
ruleExceptionList.Add(ruleException);
}
if (enableCommand)
{
var ruleException = ruleExceptionList.OfType<RuleException>().FirstOrDefault(r => r.RuleName == matchedFriendlyName && r.EndLine == 0);
ruleException?.SetEndLine(lineNumber);
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules.RuleViolations
{
public class RuleViolation : IRuleViolation
{
public RuleViolation(string fileName, string ruleName, string text, int startLine, int startColumn, RuleViolationSeverity severity)
{
FileName = fileName;
RuleName = ruleName;
Text = text;
Line = startLine;
Column = startColumn;
Severity = severity;
}
public RuleViolation(string fileName, string ruleName, int startLine, int startColumn)
{
FileName = fileName;
RuleName = ruleName;
Line = startLine;
Column = startColumn;
}
public RuleViolation(string ruleName, int startLine, int startColumn)
{
RuleName = ruleName;
Line = startLine;
Column = startColumn;
}
public int Column { get; set; }
public string FileName { get; set; }
public int Line { get; set; }
public string RuleName { get; set; }
public RuleViolationSeverity Severity { get; set; }
public string Text { get; set; }
public override string ToString()
{
return $@"{Severity.ToString().ToUpper()}: L{Line} C{Column} {FileName} ""{Text}""";
}
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
using SQLLinter.Common.Helpers;
namespace SQLLinter.Infrastructure.Rules;
public class SchemaQualifyRule : BaseRuleVisitor, IRule
{
private readonly List<string> tableAliases = new()
{
"INSERTED",
"UPDATED",
"DELETED"
};
public override string Text => "Имя объекта без схемы: {0}";
public override void Visit(TSqlStatement node)
{
var childAliasVisitor = new ChildAliasVisitor();
node.AcceptChildren(childAliasVisitor);
tableAliases.AddRange(childAliasVisitor.TableAliases);
}
public override void Visit(NamedTableReference node) => VisitTableName(node.SchemaObject, true);
public override void Visit(CreateTableStatement node) => VisitTableName(node.SchemaObjectName, false);
public override void Visit(AlterTableStatement node) => VisitTableName(node.SchemaObjectName, false);
public override void Visit(TruncateTableStatement node) => VisitTableName(node.TableName, false);
public override void Visit(DropTableStatement node)
{
foreach (var schemaObjectName in node.Objects)
{
VisitTableName(schemaObjectName, false);
}
}
private void VisitTableName(SchemaObjectName node, bool canHaveTableAliases)
{
if (node.SchemaIdentifier != null)
{
return;
}
// не проверять схему во временных таблицах
if (node.BaseIdentifier.Value.Contains('#'))
{
return;
}
// не проверять схему псевдонимов таблиц
if (canHaveTableAliases && tableAliases.Exists(x => x.Equals(node.BaseIdentifier.Value, StringComparison.OrdinalIgnoreCase)))
{
return;
}
AddViolation(node, SQLHelpers.ObjectGetFullName(node));
}
public class ChildAliasVisitor : TSqlFragmentVisitor
{
public List<string> TableAliases { get; } = new();
public override void Visit(TableReferenceWithAlias node)
{
if (node.Alias != null)
{
TableAliases.Add(node.Alias.Value);
}
}
public override void Visit(CommonTableExpression node)
{
TableAliases.Add(node.ExpressionName.Value);
}
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
public class SelectStarRule : BaseRuleVisitor, IRule
{
private int expressionCounter;
public override string Text => "Ожидаются имена столбцов в SELECT";
public override void Visit(ExistsPredicate node)
{
var childVisitor = new ChildVisitor();
node.AcceptChildren(childVisitor);
expressionCounter += childVisitor.SelectStarExpressionCount;
}
public override void Visit(SelectStarExpression node)
{
if (expressionCounter > 0)
{
expressionCounter--;
return;
}
AddViolation(node);
}
public class ChildVisitor : TSqlFragmentVisitor
{
public int SelectStarExpressionCount { get; set; }
public override void Visit(SelectStarExpression node)
{
SelectStarExpressionCount++;
}
}
}

View File

@@ -0,0 +1,73 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
using SQLLinter.Infrastructure.Rules.Common;
using System.Text.RegularExpressions;
namespace SQLLinter.Infrastructure.Rules;
public class SemicolonTerminationRule : BaseRuleVisitor, IRule
{
private readonly IList<TSqlFragment> waitForStatements = new List<TSqlFragment>();
private readonly IList<TSqlFragment> functionReturnTypeSelectStatements = new List<TSqlFragment>();
private static Regex WhiteSpaceRegex = new Regex(@"\s", RegexOptions.Compiled);
private static Regex AllWhiteSpaceRegex = new Regex(@"^\s$", RegexOptions.Compiled);
// не принудительно завершать эти операторы точкой с запятой
private readonly Type[] typesToSkip =
{
typeof(BeginEndBlockStatement),
typeof(GoToStatement),
typeof(IndexDefinition),
typeof(LabelStatement),
typeof(WhileStatement),
typeof(IfStatement),
typeof(CreateViewStatement)
};
public override string Text => "Оператор не заканчивается точкой с запятой";
public override void Visit(WaitForStatement node)
{
waitForStatements.Add(node.Statement);
}
public override void Visit(CreateFunctionStatement node)
{
if (node.ReturnType is SelectFunctionReturnType returnType)
{
functionReturnTypeSelectStatements.Add(returnType.SelectStatement);
}
}
public override void Visit(TSqlStatement node)
{
if (Array.IndexOf(typesToSkip, node.GetType()) > -1 ||
EndsWithSemicolon(node) ||
waitForStatements.Contains(node) ||
functionReturnTypeSelectStatements.Contains(node))
{
return;
}
var dynamicSqlColumnOffset = GetDynamicSqlColumnOffset(node);
var (lastToken, column) = GetLastTokenAndColumn(node);
AddViolation(Name, Text, GetLineNumber(lastToken), column + dynamicSqlColumnOffset);
}
private static (TSqlParserToken, int) GetLastTokenAndColumn(TSqlStatement node)
{
var lastToken = node.ScriptTokenStream[node.LastTokenIndex];
var tabsOnLine = ColumnNumberCalculator.CountTabsBeforeToken(lastToken.Line, node.LastTokenIndex, node.ScriptTokenStream);
var column = ColumnNumberCalculator.GetColumnNumberAfterToken(tabsOnLine, lastToken);
return (lastToken, column);
}
private static bool EndsWithSemicolon(TSqlFragment node)
{
return node.ScriptTokenStream[node.LastTokenIndex].TokenType == TSqlTokenType.Semicolon
|| node.ScriptTokenStream[node.LastTokenIndex + 1].TokenType == TSqlTokenType.Semicolon;
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
public class TempTableDropRule : BaseRuleVisitor
{
public override string Text => "Нежелательно использовать DROP временных таблиц: {0}";
public override void Visit(DropTableStatement node)
{
var check = node.Objects.Where(o => o.BaseIdentifier.Value.StartsWith("#"));
if (check.Any())
{
AddViolation(node, string.Join(", ", check));
}
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
using SQLLinter.Common.Helpers;
namespace SQLLinter.Infrastructure.Rules;
public class TempTableModificationRule : BaseRuleVisitor
{
public override string Text => "Избегать ALTER/UPDATE/DELETE для временных таблиц: {0}";
public override void Visit(UpdateStatement node)
{
if (node.UpdateSpecification.Target is NamedTableReference tbl && tbl.SchemaObject.BaseIdentifier.Value.StartsWith("#"))
{
AddViolation(node, SQLHelpers.ObjectGetFullName(tbl.SchemaObject));
}
}
public override void Visit(DeleteStatement node)
{
if (node.DeleteSpecification.Target is NamedTableReference tbl && tbl.SchemaObject.BaseIdentifier.Value.StartsWith("#"))
{
AddViolation(node, SQLHelpers.ObjectGetFullName(tbl.SchemaObject));
}
}
public override void Visit(AlterTableStatement node)
{
if (node.SchemaObjectName.BaseIdentifier.Value.StartsWith("#"))
{
AddViolation(node, SQLHelpers.ObjectGetFullName(node.SchemaObjectName));
}
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
public class TopWithoutOrderByRule : BaseRuleVisitor
{
public override string Text => "TOP без ORDER BY может дать непредсказуемый результат";
public override void Visit(QuerySpecification node)
{
if (node.TopRowFilter != null && node.OrderByClause == null)
{
AddViolation(node.TopRowFilter);
}
base.Visit(node);
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
using SQLLinter.Common.Helpers;
namespace SQLLinter.Infrastructure.Rules;
public class UnicodeStringRule : BaseRuleVisitor, IRule
{
public override string Text => "Использование символов Юникода в строке, отличной от Юникода";
public override void Visit(StringLiteral node)
{
if (node.IsNational)
{
return;
}
if (!IsAscii(node.Value))
{
AddViolation(Name, Text, node.StartLine, node.StartColumn);
}
}
private static bool IsAscii(string part)
{
return SQLHelpers.IsValidForEncoding(part, "windows-1251");
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
public class UnionRule : BaseRuleVisitor
{
public override string Text => "Избегать UNION, использовать UNION ALL.";
public override void Visit(BinaryQueryExpression node)
{
if (node.BinaryQueryExpressionType == BinaryQueryExpressionType.Union && !node.All)
{
AddViolation(node);
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
public class UpdateWhereRule : BaseRuleVisitor, IRule
{
public override string Text => "Ожидается выражение WHERE для оператора UPDATE: {0}";
public override void Visit(UpdateSpecification node)
{
if (node.WhereClause != null)
{
return;
}
string name = string.Join("", node.Target.ScriptTokenStream
.Skip(node.Target.FirstTokenIndex)
.Take(node.Target.LastTokenIndex - node.Target.FirstTokenIndex + 1)
.Select(t => t.Text)
);
AddViolation(node, name);
}
}

View File

@@ -0,0 +1,64 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
public class UpperLowerRule : BaseRuleVisitor, IRule
{
public override string Text => "Использование функций UPPER или LOWER при выполнении сравнений в операторах SELECT не требуется при запуске базы данных в режиме без учета регистра.";
public override void Visit(SelectStatement node)
{
var visitor = new ChildQueryComparisonVisitor();
node.Accept(visitor);
if (visitor.QueryExpressionUpperLowerFunctionFound)
{
AddViolation(node);
}
}
public class ChildQueryComparisonVisitor : TSqlFragmentVisitor
{
public bool QueryExpressionUpperLowerFunctionFound { get; private set; }
public override void Visit(QueryExpression node)
{
var visitor = new ChildBooleanComparisonVisitor();
node.Accept(visitor);
if (visitor.UpperLowerFunctionCallInComparison)
{
QueryExpressionUpperLowerFunctionFound = true;
}
}
}
public class ChildBooleanComparisonVisitor : TSqlFragmentVisitor
{
public bool UpperLowerFunctionCallInComparison { get; private set; }
public override void Visit(BooleanComparisonExpression node)
{
var visitor = new ChildFunctionCallVisitor();
node.Accept(visitor);
if (visitor.UpperLowerFound)
{
UpperLowerFunctionCallInComparison = true;
}
}
}
public class ChildFunctionCallVisitor : TSqlFragmentVisitor
{
public bool UpperLowerFound { get; private set; }
public override void Visit(FunctionCall node)
{
if (node.FunctionName.Value.Equals("UPPER", StringComparison.OrdinalIgnoreCase) ||
node.FunctionName.Value.Equals("LOWER", StringComparison.OrdinalIgnoreCase))
{
UpperLowerFound = true;
}
}
}
}

View File

@@ -0,0 +1,158 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
using SQLLinter.Common.Helpers;
using SQLLinter.Core;
namespace SQLLinter.Infrastructure.Rules;
public class UserFunctionJoinRule : BaseRuleVisitor
{
public override string Text => "Запрещено использование пользовательских функций внутри запросов: {0}";
private bool _inQueryContext;
// Входим в контекст запроса
public override void Visit(QuerySpecification node)
{
_inQueryContext = true;
// Проверяем FROM
if (node.FromClause != null)
{
var tables = node.FromClause.TableReferences;
// Разрешаем ровно одну табличную функцию
if (!(tables.Count == 1 && tables[0] is SchemaObjectFunctionTableReference ft && IsUserFunction(ft.SchemaObject?.BaseIdentifier?.Value)))
{
foreach (var tr in tables)
CheckTableReference(tr);
}
}
base.Visit(node);
_inQueryContext = false;
}
// Скалярные функции
public override void Visit(FunctionCall node)
{
if (_inQueryContext && IsUserFunction(node.FunctionName.Value))
{
AddViolationFunction(node);
}
base.Visit(node);
}
// Табличные функции
public override void Visit(SchemaObjectFunctionTableReference node)
{
// Проверка делается в QuerySpecification - здесь ничего не делаем
base.Visit(node);
}
// Подзапросы
public override void Visit(ScalarSubquery node)
{
node.QueryExpression.Accept(this);
base.Visit(node);
}
public override void Visit(QueryDerivedTable node)
{
node.QueryExpression.Accept(this);
base.Visit(node);
}
public override void Visit(QualifiedJoin node)
{
if (node.SearchCondition != null)
node.SearchCondition.Accept(this);
if (node.FirstTableReference != null)
CheckTableReference(node.FirstTableReference);
if (node.SecondTableReference != null)
CheckTableReference(node.SecondTableReference);
base.Visit(node);
}
public override void Visit(UnqualifiedJoin node)
{
// Проверяем APPLY
if (node.UnqualifiedJoinType == UnqualifiedJoinType.CrossApply ||
node.UnqualifiedJoinType == UnqualifiedJoinType.OuterApply)
{
if (node.SecondTableReference is SchemaObjectFunctionTableReference ft &&
IsUserFunction(ft.SchemaObject?.BaseIdentifier?.Value))
{
AddViolation(ft, SQLHelpers.ObjectGetFullName(ft.SchemaObject));
}
}
// Обходим обе стороны
if (node.FirstTableReference != null)
CheckTableReference(node.FirstTableReference);
if (node.SecondTableReference != null)
CheckTableReference(node.SecondTableReference);
base.Visit(node);
}
// ------------------------
// Вспомогательные методы
// ------------------------
private void AddViolationFunction(FunctionCall func)
{
string ident = "";
if (func.CallTarget is MultiPartIdentifierCallTarget callTarget)
{
ident = SQLHelpers.ObjectGetFullName(callTarget.MultiPartIdentifier.Identifiers);
if (!string.IsNullOrWhiteSpace(ident)) ident += ".";
}
ident += "[" + func.FunctionName.Value + "]";
AddViolation(func, ident);
}
private void CheckTableReference(TableReference tr)
{
switch (tr)
{
case SchemaObjectFunctionTableReference ft:
if (IsUserFunction(ft.SchemaObject?.BaseIdentifier?.Value))
AddViolation(ft, SQLHelpers.ObjectGetFullName(ft.SchemaObject));
break;
case QueryDerivedTable qdt:
qdt.QueryExpression.Accept(this);
break;
case QualifiedJoin qj:
if (qj.FirstTableReference != null)
CheckTableReference(qj.FirstTableReference);
if (qj.SecondTableReference != null)
CheckTableReference(qj.SecondTableReference);
if (qj.SearchCondition != null)
qj.SearchCondition.Accept(this);
break;
case UnqualifiedJoin aj:
if (aj.FirstTableReference != null)
CheckTableReference(aj.FirstTableReference);
if (aj.SecondTableReference != null)
CheckTableReference(aj.SecondTableReference);
break;
}
}
private bool IsUserFunction(string? functionName)
{
return !string.IsNullOrEmpty(functionName) &&
!Constants.SystemFunctions.Contains(functionName);
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
public class WhereInSelectRule : BaseRuleVisitor
{
public override string Text => "Запрещено IN (SELECT ...), используйте EXISTS/NOT EXISTS.";
public override void Visit(InPredicate node)
{
if (node.Subquery != null)
{
AddViolation(node);
}
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Common;
namespace SQLLinter.Infrastructure.Rules;
public class XmlJsonParsingRule : BaseRuleVisitor
{
public override string Text => "Запрещён парсинг XML/JSON.";
public override void Visit(FunctionCall node)
{
var fn = node.FunctionName.Value.ToUpperInvariant();
if (fn.Contains("OPENXML") || fn.Contains("OPENJSON") || fn.Contains("NODES"))
{
AddViolation(node);
}
}
}