Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/Evolve/Configuration/IEvolveConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using EvolveDb.Migration;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using EvolveDb.Migration;

namespace EvolveDb.Configuration
{
Expand Down Expand Up @@ -218,5 +218,15 @@ public interface IEvolveConfiguration
/// that replaces the built-in ones (<see cref="FileMigrationLoader"/> <see cref="EmbeddedResourceMigrationLoader"/>)
/// </summary>
IMigrationLoader MigrationLoader { get; }

/// <summary>
/// When true, enables SQL linting to detect potentially unsafe DDL patterns. (default: false)
/// </summary>
bool EnableSqlLint { get; }

/// <summary>
/// Defines how SQL lint failures should be handled. (default: Warning)
/// </summary>
SqlLintFailureLevel SqlLintFailureLevel { get; }
}
}
18 changes: 18 additions & 0 deletions src/Evolve/Configuration/SqlLintFailureLevel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace EvolveDb.Configuration
{
/// <summary>
/// Defines how SQL lint failures should be handled.
/// </summary>
public enum SqlLintFailureLevel
{
/// <summary>
/// Log lint issues as warnings and continue execution.
/// </summary>
Warning,

/// <summary>
/// Treat lint issues as errors and stop execution.
/// </summary>
Error
}
}
206 changes: 206 additions & 0 deletions src/Evolve/Dialect/SqlLintIssue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;

namespace EvolveDb.Dialect
{
/// <summary>
/// Represents a SQL lint issue found during analysis.
/// </summary>
public class SqlLintIssue
{
public SqlLintIssue(string message, int lineNumber, string statement)
{
Message = message;
LineNumber = lineNumber;
Statement = statement;
}

/// <summary>
/// Gets the description of the lint issue.
/// </summary>
public string Message { get; }

/// <summary>
/// Gets the line number where the issue was found.
/// </summary>
public int LineNumber { get; }

/// <summary>
/// Gets the SQL statement that contains the issue.
/// </summary>
public string Statement { get; }
}

/// <summary>
/// Analyzes SQL statements for potentially unsafe DDL patterns.
/// </summary>
internal class SqlLinter
{
private static readonly Regex DropTablePattern = new Regex(
@"^\s*DROP\s+TABLE\s+(?!IF\s+EXISTS\s+)\S+",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex CreateTablePattern = new Regex(
@"^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS\s+)\S+",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex DropSchemaPattern = new Regex(
@"^\s*DROP\s+(?:SCHEMA|DATABASE)\s+(?!IF\s+EXISTS\s+)\S+",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex CreateSchemaPattern = new Regex(
@"^\s*CREATE\s+(?:SCHEMA|DATABASE)\s+(?!IF\s+NOT\s+EXISTS\s+)\S+",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex DropViewPattern = new Regex(
@"^\s*DROP\s+VIEW\s+(?!IF\s+EXISTS\s+)\S+",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex CreateViewPattern = new Regex(
@"^\s*CREATE\s+VIEW\s+(?!IF\s+NOT\s+EXISTS\s+)\S+",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex DropSequencePattern = new Regex(
@"^\s*DROP\s+SEQUENCE\s+(?!IF\s+EXISTS\s+)\S+",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);

private static readonly Regex CreateSequencePattern = new Regex(
@"^\s*CREATE\s+SEQUENCE\s+(?!IF\s+NOT\s+EXISTS\s+)\S+",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);

/// <summary>
/// Analyzes SQL statements for unsafe DDL patterns.
/// </summary>
/// <param name="statements">The SQL statements to analyze.</param>
/// <returns>A list of lint issues found.</returns>
public static IEnumerable<SqlLintIssue> AnalyzeStatements(IEnumerable<SqlStatement> statements)
{
var issues = new List<SqlLintIssue>();

foreach (var statement in statements)
{
issues.AddRange(AnalyzeStatement(statement));
}

return issues;
}

/// <summary>
/// Analyzes a single SQL statement for unsafe DDL patterns.
/// </summary>
/// <param name="statement">The SQL statement to analyze.</param>
/// <returns>A list of lint issues found in the statement.</returns>
private static IEnumerable<SqlLintIssue> AnalyzeStatement(SqlStatement statement)
{
var issues = new List<SqlLintIssue>();
var sql = statement.Sql?.Trim();

if (string.IsNullOrWhiteSpace(sql))
{
return issues;
}

// Clean up the SQL to remove comments and string literals to avoid false positives
var cleanedSql = CleanSqlForAnalysis(sql);

// Check for DROP TABLE without IF EXISTS
if (DropTablePattern.IsMatch(cleanedSql))
{
issues.Add(new SqlLintIssue(
"DROP TABLE statement should use 'IF EXISTS' to avoid errors if the table doesn't exist",
statement.LineNumber,
sql));
}

// Check for CREATE TABLE without IF NOT EXISTS
if (CreateTablePattern.IsMatch(cleanedSql))
{
issues.Add(new SqlLintIssue(
"CREATE TABLE statement should use 'IF NOT EXISTS' to avoid errors if the table already exists",
statement.LineNumber,
sql));
}

// Check for DROP SCHEMA/DATABASE without IF EXISTS
if (DropSchemaPattern.IsMatch(cleanedSql))
{
issues.Add(new SqlLintIssue(
"DROP SCHEMA/DATABASE statement should use 'IF EXISTS' to avoid errors if it doesn't exist",
statement.LineNumber,
sql));
}

// Check for CREATE SCHEMA/DATABASE without IF NOT EXISTS
if (CreateSchemaPattern.IsMatch(cleanedSql))
{
issues.Add(new SqlLintIssue(
"CREATE SCHEMA/DATABASE statement should use 'IF NOT EXISTS' to avoid errors if it already exists",
statement.LineNumber,
sql));
}

// Check for DROP VIEW without IF EXISTS
if (DropViewPattern.IsMatch(cleanedSql))
{
issues.Add(new SqlLintIssue(
"DROP VIEW statement should use 'IF EXISTS' to avoid errors if the view doesn't exist",
statement.LineNumber,
sql));
}

// Check for CREATE VIEW without IF NOT EXISTS (note: not all databases support this)
if (CreateViewPattern.IsMatch(cleanedSql))
{
issues.Add(new SqlLintIssue(
"CREATE VIEW statement should use 'IF NOT EXISTS' where supported to avoid errors if the view already exists",
statement.LineNumber,
sql));
}

// Check for DROP SEQUENCE without IF EXISTS
if (DropSequencePattern.IsMatch(cleanedSql))
{
issues.Add(new SqlLintIssue(
"DROP SEQUENCE statement should use 'IF EXISTS' to avoid errors if the sequence doesn't exist",
statement.LineNumber,
sql));
}

// Check for CREATE SEQUENCE without IF NOT EXISTS
if (CreateSequencePattern.IsMatch(cleanedSql))
{
issues.Add(new SqlLintIssue(
"CREATE SEQUENCE statement should use 'IF NOT EXISTS' to avoid errors if the sequence already exists",
statement.LineNumber,
sql));
}

return issues;
}

/// <summary>
/// Cleans SQL by removing comments and string literals to avoid false positives during analysis.
/// </summary>
/// <param name="sql">The SQL to clean.</param>
/// <returns>The cleaned SQL.</returns>
private static string CleanSqlForAnalysis(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
return string.Empty;

// Remove single-line comments (-- comments)
sql = Regex.Replace(sql, @"--.*$", "", RegexOptions.Multiline);

// Remove multi-line comments (/* comments */)
sql = Regex.Replace(sql, @"/\*.*?\*/", "", RegexOptions.Singleline);

// Remove single-quoted string literals
sql = Regex.Replace(sql, @"'(?:[^'\\]|\\.)*'", " ", RegexOptions.Singleline);

// Remove double-quoted string literals
sql = Regex.Replace(sql, @"""(?:[^""\\\\]|\\.)*""", " ", RegexOptions.Singleline);

return sql;
}
}
}
56 changes: 51 additions & 5 deletions src/Evolve/Dialect/SqlStatementBuilderBase.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
using System.Collections.Generic;
using EvolveDb.Configuration;
using EvolveDb.Migration;
using EvolveDb.Utilities;
using System.Collections.Generic;
using System.Linq;

namespace EvolveDb.Dialect
{
/// <summary>
/// A base class used to parse a SQL script and return a list of sql statements.
/// Each statement can then be executed depending its own database constraints,enlisted or not in a transaction.
/// Each statement can then be executed depending on its own database constraints,enlisted or not in a transaction.
/// </summary>
internal abstract class SqlStatementBuilderBase
{
/// <summary>
/// Gets the database bacth delimiter.
/// Gets the database batch delimiter.
/// </summary>
public abstract string? BatchDelimiter { get; }

Expand All @@ -26,17 +28,61 @@ internal abstract class SqlStatementBuilderBase
/// <param name="placeholders"> The placeholders to replace. </param>
/// <returns> A <see cref="List{SqlStatement}"/> to execute individually in a command. </returns>
public virtual IEnumerable<SqlStatement> LoadSqlStatements(MigrationScript migrationScript, Dictionary<string, string> placeholders)
{
return LoadSqlStatements(migrationScript, placeholders, null, null);
}

/// <summary>
/// Returns a <see cref="List{SqlStatement}"/> given a <paramref name="migrationScript"/> with optional SQL linting.
/// </summary>
/// <remarks>
/// Placeholders are replaced by their values in the migration script.
/// The result is then parsed in sql statements: <see cref="Parse(string, bool)"/>.
/// If linting is enabled, statements are analyzed for unsafe DDL patterns.
/// </remarks>
/// <param name="migrationScript"> The sql script to parse. </param>
/// <param name="placeholders"> The placeholders to replace. </param>
/// <param name="enableSqlLint"> Whether to enable SQL linting. </param>
/// <param name="sqlLintFailureLevel"> How to handle lint failures. </param>
/// <param name="logAction"> Optional logging action for lint warnings. </param>
/// <returns> A <see cref="List{SqlStatement}"/> to execute individually in a command. </returns>
public virtual IEnumerable<SqlStatement> LoadSqlStatements(MigrationScript migrationScript, Dictionary<string, string> placeholders, bool? enableSqlLint, SqlLintFailureLevel? sqlLintFailureLevel, System.Action<string>? logAction = null)
{
Check.NotNull(migrationScript, nameof(migrationScript));
Check.NotNull(placeholders, nameof(placeholders));

string sql = migrationScript.Content; // copy the content of the migration script
foreach (var entry in placeholders)
{
sql = sql.Replace(entry.Key, entry.Value);
}

return Parse(sql, migrationScript.IsTransactionEnabled);
var statements = Parse(sql, migrationScript.IsTransactionEnabled).ToList();

// Perform SQL linting if enabled
if (enableSqlLint == true)
{
var lintIssues = SqlLinter.AnalyzeStatements(statements).ToList();
if (lintIssues.Any())
{
var failureLevel = sqlLintFailureLevel ?? SqlLintFailureLevel.Warning;

if (failureLevel == SqlLintFailureLevel.Error)
{
throw new EvolveSqlLintException(lintIssues);
}
else if (logAction != null)
{
// Log warnings
foreach (var issue in lintIssues)
{
var lineInfo = issue.LineNumber > 0 ? $" (line {issue.LineNumber})" : "";
logAction($"SQL Lint Warning in {migrationScript.Name}{lineInfo}: {issue.Message}");
}
}
}
}

return statements;
}

/// <summary>
Expand Down
Loading