diff --git a/src/Evolve/Configuration/IEvolveConfiguration.cs b/src/Evolve/Configuration/IEvolveConfiguration.cs
index aad44a61..3afdd20a 100644
--- a/src/Evolve/Configuration/IEvolveConfiguration.cs
+++ b/src/Evolve/Configuration/IEvolveConfiguration.cs
@@ -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
{
@@ -218,5 +218,15 @@ public interface IEvolveConfiguration
/// that replaces the built-in ones ( )
///
IMigrationLoader MigrationLoader { get; }
+
+ ///
+ /// When true, enables SQL linting to detect potentially unsafe DDL patterns. (default: false)
+ ///
+ bool EnableSqlLint { get; }
+
+ ///
+ /// Defines how SQL lint failures should be handled. (default: Warning)
+ ///
+ SqlLintFailureLevel SqlLintFailureLevel { get; }
}
}
diff --git a/src/Evolve/Configuration/SqlLintFailureLevel.cs b/src/Evolve/Configuration/SqlLintFailureLevel.cs
new file mode 100644
index 00000000..d0debcb3
--- /dev/null
+++ b/src/Evolve/Configuration/SqlLintFailureLevel.cs
@@ -0,0 +1,18 @@
+namespace EvolveDb.Configuration
+{
+ ///
+ /// Defines how SQL lint failures should be handled.
+ ///
+ public enum SqlLintFailureLevel
+ {
+ ///
+ /// Log lint issues as warnings and continue execution.
+ ///
+ Warning,
+
+ ///
+ /// Treat lint issues as errors and stop execution.
+ ///
+ Error
+ }
+}
diff --git a/src/Evolve/Dialect/SqlLintIssue.cs b/src/Evolve/Dialect/SqlLintIssue.cs
new file mode 100644
index 00000000..e673331a
--- /dev/null
+++ b/src/Evolve/Dialect/SqlLintIssue.cs
@@ -0,0 +1,206 @@
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+
+namespace EvolveDb.Dialect
+{
+ ///
+ /// Represents a SQL lint issue found during analysis.
+ ///
+ public class SqlLintIssue
+ {
+ public SqlLintIssue(string message, int lineNumber, string statement)
+ {
+ Message = message;
+ LineNumber = lineNumber;
+ Statement = statement;
+ }
+
+ ///
+ /// Gets the description of the lint issue.
+ ///
+ public string Message { get; }
+
+ ///
+ /// Gets the line number where the issue was found.
+ ///
+ public int LineNumber { get; }
+
+ ///
+ /// Gets the SQL statement that contains the issue.
+ ///
+ public string Statement { get; }
+ }
+
+ ///
+ /// Analyzes SQL statements for potentially unsafe DDL patterns.
+ ///
+ 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);
+
+ ///
+ /// Analyzes SQL statements for unsafe DDL patterns.
+ ///
+ /// The SQL statements to analyze.
+ /// A list of lint issues found.
+ public static IEnumerable AnalyzeStatements(IEnumerable statements)
+ {
+ var issues = new List();
+
+ foreach (var statement in statements)
+ {
+ issues.AddRange(AnalyzeStatement(statement));
+ }
+
+ return issues;
+ }
+
+ ///
+ /// Analyzes a single SQL statement for unsafe DDL patterns.
+ ///
+ /// The SQL statement to analyze.
+ /// A list of lint issues found in the statement.
+ private static IEnumerable AnalyzeStatement(SqlStatement statement)
+ {
+ var issues = new List();
+ 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;
+ }
+
+ ///
+ /// Cleans SQL by removing comments and string literals to avoid false positives during analysis.
+ ///
+ /// The SQL to clean.
+ /// The cleaned SQL.
+ 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Evolve/Dialect/SqlStatementBuilderBase.cs b/src/Evolve/Dialect/SqlStatementBuilderBase.cs
index c7217266..fc779946 100644
--- a/src/Evolve/Dialect/SqlStatementBuilderBase.cs
+++ b/src/Evolve/Dialect/SqlStatementBuilderBase.cs
@@ -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
{
///
/// 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.
///
internal abstract class SqlStatementBuilderBase
{
///
- /// Gets the database bacth delimiter.
+ /// Gets the database batch delimiter.
///
public abstract string? BatchDelimiter { get; }
@@ -26,17 +28,61 @@ internal abstract class SqlStatementBuilderBase
/// The placeholders to replace.
/// A to execute individually in a command.
public virtual IEnumerable LoadSqlStatements(MigrationScript migrationScript, Dictionary placeholders)
+ {
+ return LoadSqlStatements(migrationScript, placeholders, null, null);
+ }
+
+ ///
+ /// Returns a given a with optional SQL linting.
+ ///
+ ///
+ /// Placeholders are replaced by their values in the migration script.
+ /// The result is then parsed in sql statements: .
+ /// If linting is enabled, statements are analyzed for unsafe DDL patterns.
+ ///
+ /// The sql script to parse.
+ /// The placeholders to replace.
+ /// Whether to enable SQL linting.
+ /// How to handle lint failures.
+ /// Optional logging action for lint warnings.
+ /// A to execute individually in a command.
+ public virtual IEnumerable LoadSqlStatements(MigrationScript migrationScript, Dictionary placeholders, bool? enableSqlLint, SqlLintFailureLevel? sqlLintFailureLevel, System.Action? 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;
}
///
diff --git a/src/Evolve/Evolve.cs b/src/Evolve/Evolve.cs
index 64510adb..128ea353 100644
--- a/src/Evolve/Evolve.cs
+++ b/src/Evolve/Evolve.cs
@@ -1,4 +1,11 @@
-using System;
+using ConsoleTables;
+using EvolveDb.Configuration;
+using EvolveDb.Connection;
+using EvolveDb.Dialect;
+using EvolveDb.Metadata;
+using EvolveDb.Migration;
+using EvolveDb.Utilities;
+using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Diagnostics;
@@ -8,13 +15,6 @@
using System.Text;
using System.Threading;
using System.Transactions;
-using ConsoleTables;
-using EvolveDb.Configuration;
-using EvolveDb.Connection;
-using EvolveDb.Dialect;
-using EvolveDb.Metadata;
-using EvolveDb.Migration;
-using EvolveDb.Utilities;
[assembly: InternalsVisibleTo("Evolve.Tests")]
namespace EvolveDb
@@ -77,6 +77,8 @@ public string MetadataTableSchema
public bool RetryRepeatableMigrationsUntilNoError { get; set; }
public TransactionKind TransactionMode { get; set; } = TransactionKind.CommitEach;
public bool SkipNextMigrations { get; set; } = false;
+ public bool EnableSqlLint { get; set; } = false;
+ public SqlLintFailureLevel SqlLintFailureLevel { get; set; } = SqlLintFailureLevel.Warning;
private IMigrationLoader? _migrationLoader;
public IMigrationLoader MigrationLoader
@@ -243,7 +245,7 @@ public IEnumerable Info()
{
startVersion = StartVersion;
}
-
+
var rows = new List();
rows.AddRange(GetAllPendingSchemaUI(db, metadata));
if (isEvolveInitialized)
@@ -619,7 +621,7 @@ private IEnumerable GetAllPendingRepeatableMigration(IEvolveMet
var pendingMigrations = new List();
var appliedMigrations = metadata.GetAllAppliedRepeatableMigration();
var scripts = MigrationLoader.GetRepeatableMigrations();
-
+
foreach (var script in scripts)
{
var appliedMigration = appliedMigrations.Where(x => x.Name == script.Name).OrderBy(x => x.InstalledOn).LastOrDefault();
@@ -791,7 +793,7 @@ private void ExecuteMigration(MigrationScript migration, DatabaseHelper db)
try
{
stopWatch.Start();
- foreach (var statement in db.SqlStatementBuilder.LoadSqlStatements(migration, Placeholders))
+ foreach (var statement in db.SqlStatementBuilder.LoadSqlStatements(migration, Placeholders, EnableSqlLint, SqlLintFailureLevel, _log))
{
if (statement.MustExecuteInTransaction)
{
@@ -819,7 +821,7 @@ private void ExecuteMigration(MigrationScript migration, DatabaseHelper db)
stopWatch.Stop();
TotalTimeElapsedInMs += stopWatch.ElapsedMilliseconds;
db.WrappedConnection.TryRollback();
-
+
if (TransactionMode == TransactionKind.CommitEach)
{
metadata.SaveMigration(migration, success: false, stopWatch.Elapsed);
diff --git a/src/Evolve/Exception/EvolveSqlLintException.cs b/src/Evolve/Exception/EvolveSqlLintException.cs
new file mode 100644
index 00000000..974ddfa6
--- /dev/null
+++ b/src/Evolve/Exception/EvolveSqlLintException.cs
@@ -0,0 +1,45 @@
+using EvolveDb.Dialect;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace EvolveDb
+{
+ ///
+ /// Exception thrown when SQL lint issues are found and configured to fail on errors.
+ ///
+ public class EvolveSqlLintException : EvolveException
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The SQL lint issues that caused the exception.
+ public EvolveSqlLintException(IEnumerable issues)
+ : base(BuildMessage(issues))
+ {
+ Issues = issues?.ToList() ?? [];
+ }
+
+ ///
+ /// Gets the SQL lint issues that caused the exception.
+ ///
+ public IReadOnlyList Issues { get; }
+
+ private static string BuildMessage(IEnumerable issues)
+ {
+ var issueList = issues?.ToList() ?? [];
+ if (!issueList.Any())
+ {
+ return "SQL lint validation failed with no specific issues.";
+ }
+
+ var message = $"SQL lint validation failed with {issueList.Count} issue(s):\n";
+ foreach (var issue in issueList)
+ {
+ var lineInfo = issue.LineNumber > 0 ? $" (line {issue.LineNumber})" : "";
+ message += $"- {issue.Message}{lineInfo}\n";
+ }
+
+ return message;
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Evolve.Tests/Dialect/SqlLintIntegrationTest.cs b/test/Evolve.Tests/Dialect/SqlLintIntegrationTest.cs
new file mode 100644
index 00000000..22967120
--- /dev/null
+++ b/test/Evolve.Tests/Dialect/SqlLintIntegrationTest.cs
@@ -0,0 +1,161 @@
+using EvolveDb.Configuration;
+using EvolveDb.Dialect;
+using EvolveDb.Migration;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+
+namespace EvolveDb.Tests.Dialect
+{
+ public class SqlLintIntegrationTest
+ {
+ private class TestSqlStatementBuilder : SqlStatementBuilderBase
+ {
+ public override string BatchDelimiter => ";";
+
+ protected override IEnumerable Parse(string sqlScript, bool transactionEnabled)
+ {
+ if (string.IsNullOrWhiteSpace(sqlScript))
+ return new List();
+
+ var statements = sqlScript.Split(';', StringSplitOptions.RemoveEmptyEntries);
+ var lineNumber = 1;
+
+ return statements.Select(stmt => new SqlStatement(stmt.Trim(), transactionEnabled, lineNumber++));
+ }
+ }
+
+ [Fact]
+ public void LoadSqlStatements_WithLintingDisabled_DoesNotPerformLinting()
+ {
+ // Arrange
+ var builder = new TestSqlStatementBuilder();
+ var migration = new EmbeddedResourceMigrationScript("1.0", "test", "test.sql",
+ new System.IO.MemoryStream("DROP TABLE users"u8.ToArray()),
+ Metadata.MetadataType.Migration);
+ var placeholders = new Dictionary();
+
+ // Act - using original method (no linting)
+ var statements = builder.LoadSqlStatements(migration, placeholders);
+
+ // Assert - should return statements without throwing
+ Assert.Single(statements);
+ }
+
+ [Fact]
+ public void LoadSqlStatements_WithLintingEnabledAndWarnings_LogsWarningsAndReturnsStatements()
+ {
+ // Arrange
+ var builder = new TestSqlStatementBuilder();
+ var migration = new EmbeddedResourceMigrationScript("1.0", "test", "test.sql",
+ new System.IO.MemoryStream("DROP TABLE users"u8.ToArray()),
+ Metadata.MetadataType.Migration);
+ var placeholders = new Dictionary();
+ var logMessages = new List();
+
+ // Act
+ var statements = builder.LoadSqlStatements(migration, placeholders,
+ enableSqlLint: true,
+ sqlLintFailureLevel: SqlLintFailureLevel.Warning,
+ logAction: msg => logMessages.Add(msg));
+
+ // Assert
+ Assert.Single(statements);
+ Assert.Single(logMessages);
+ Assert.Contains("SQL Lint Warning", logMessages[0]);
+ Assert.Contains("DROP TABLE", logMessages[0]);
+ }
+
+ [Fact]
+ public void LoadSqlStatements_WithLintingEnabledAndErrors_ThrowsException()
+ {
+ // Arrange
+ var builder = new TestSqlStatementBuilder();
+ var migration = new EmbeddedResourceMigrationScript("1.0", "test", "test.sql",
+ new System.IO.MemoryStream("DROP TABLE users;CREATE TABLE products (id INT)"u8.ToArray()),
+ Metadata.MetadataType.Migration);
+ var placeholders = new Dictionary();
+
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ builder.LoadSqlStatements(migration, placeholders,
+ enableSqlLint: true,
+ sqlLintFailureLevel: SqlLintFailureLevel.Error));
+
+ Assert.Equal(2, exception.Issues.Count);
+ Assert.Contains("DROP TABLE", exception.Message);
+ Assert.Contains("CREATE TABLE", exception.Message);
+ }
+
+ [Fact]
+ public void LoadSqlStatements_WithSafeSQLAndLintingEnabled_ReturnsStatementsWithoutIssues()
+ {
+ // Arrange
+ var builder = new TestSqlStatementBuilder();
+ var migration = new EmbeddedResourceMigrationScript("1.0", "test", "test.sql",
+ new System.IO.MemoryStream("DROP TABLE IF EXISTS users;CREATE TABLE IF NOT EXISTS products (id INT)"u8.ToArray()),
+ Metadata.MetadataType.Migration);
+ var placeholders = new Dictionary();
+ var logMessages = new List();
+
+ // Act
+ var statements = builder.LoadSqlStatements(migration, placeholders,
+ enableSqlLint: true,
+ sqlLintFailureLevel: SqlLintFailureLevel.Error,
+ logAction: msg => logMessages.Add(msg));
+
+ // Assert
+ Assert.Equal(2, statements.Count());
+ Assert.Empty(logMessages); // No warnings should be logged
+ }
+
+ [Fact]
+ public void LoadSqlStatements_WithPlaceholdersAndLinting_ProcessesPlaceholdersBeforeLinting()
+ {
+ // Arrange
+ var builder = new TestSqlStatementBuilder();
+ var migration = new EmbeddedResourceMigrationScript("1.0", "test", "test.sql",
+ new System.IO.MemoryStream("DROP TABLE ${table_name}"u8.ToArray()),
+ Metadata.MetadataType.Migration);
+ var placeholders = new Dictionary { { "${table_name}", "users" } };
+ var logMessages = new List();
+
+ // Act
+ var statements = builder.LoadSqlStatements(migration, placeholders,
+ enableSqlLint: true,
+ sqlLintFailureLevel: SqlLintFailureLevel.Warning,
+ logAction: msg => logMessages.Add(msg));
+
+ // Assert
+ var sqlStatements = statements as SqlStatement[] ?? statements.ToArray();
+ Assert.Single(sqlStatements);
+ Assert.Single(logMessages);
+ Assert.Contains("DROP TABLE", logMessages[0]);
+
+ // Verify placeholder was replaced
+ var statement = sqlStatements.First();
+ Assert.Contains("users", statement.Sql);
+ Assert.DoesNotContain("${table_name}", statement.Sql);
+ }
+
+ [Fact]
+ public void LoadSqlStatements_WithNullLintingParams_UsesDefaultBehavior()
+ {
+ // Arrange
+ var builder = new TestSqlStatementBuilder();
+ var migration = new EmbeddedResourceMigrationScript("1.0", "test", "test.sql",
+ new System.IO.MemoryStream("DROP TABLE users"u8.ToArray()),
+ Metadata.MetadataType.Migration);
+ var placeholders = new Dictionary();
+
+ // Act - null linting params should disable linting
+ var statements = builder.LoadSqlStatements(migration, placeholders,
+ enableSqlLint: null,
+ sqlLintFailureLevel: null);
+
+ // Assert - should work without linting
+ Assert.Single(statements);
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Evolve.Tests/Dialect/SqlLinterTest.cs b/test/Evolve.Tests/Dialect/SqlLinterTest.cs
new file mode 100644
index 00000000..c012c001
--- /dev/null
+++ b/test/Evolve.Tests/Dialect/SqlLinterTest.cs
@@ -0,0 +1,242 @@
+using EvolveDb.Dialect;
+using System.Linq;
+using Xunit;
+
+namespace EvolveDb.Tests.Dialect
+{
+ public class SqlLinterTest
+ {
+ [Fact]
+ public void AnalyzeStatements_WithSafeStatements_ReturnsNoIssues()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("CREATE TABLE IF NOT EXISTS users (id INT)", true),
+ new SqlStatement("DROP TABLE IF EXISTS temp_table", true),
+ new SqlStatement("CREATE SEQUENCE IF NOT EXISTS test_sequence", true),
+ new SqlStatement("DROP SEQUENCE IF EXISTS old_sequence", true),
+ new SqlStatement("INSERT INTO users VALUES (1)", true)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements);
+
+ // Assert
+ Assert.Empty(issues);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithDropTableWithoutIfExists_ReturnsIssue()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("DROP TABLE users", true, 5)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Single(issues);
+ Assert.Equal(5, issues[0].LineNumber);
+ Assert.Contains("DROP TABLE", issues[0].Message);
+ Assert.Contains("IF EXISTS", issues[0].Message);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithCreateTableWithoutIfNotExists_ReturnsIssue()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("CREATE TABLE users (id INT)", true, 10)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Single(issues);
+ Assert.Equal(10, issues[0].LineNumber);
+ Assert.Contains("CREATE TABLE", issues[0].Message);
+ Assert.Contains("IF NOT EXISTS", issues[0].Message);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithDropSchemaWithoutIfExists_ReturnsIssue()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("DROP SCHEMA test_schema", true, 15)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Single(issues);
+ Assert.Equal(15, issues[0].LineNumber);
+ Assert.Contains("DROP SCHEMA", issues[0].Message);
+ Assert.Contains("IF EXISTS", issues[0].Message);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithDropDatabaseWithoutIfExists_ReturnsIssue()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("DROP DATABASE test_db", true, 20)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Single(issues);
+ Assert.Equal(20, issues[0].LineNumber);
+ Assert.Contains("DROP", issues[0].Message);
+ Assert.Contains("DATABASE", issues[0].Message);
+ Assert.Contains("IF EXISTS", issues[0].Message);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithCreateSchemaWithoutIfNotExists_ReturnsIssue()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("CREATE SCHEMA test_schema", true, 25)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Single(issues);
+ Assert.Equal(25, issues[0].LineNumber);
+ Assert.Contains("CREATE SCHEMA", issues[0].Message);
+ Assert.Contains("IF NOT EXISTS", issues[0].Message);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithDropSequenceWithoutIfExists_ReturnsIssue()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("DROP SEQUENCE test_sequence", true, 30)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Single(issues);
+ Assert.Equal(30, issues[0].LineNumber);
+ Assert.Contains("DROP SEQUENCE", issues[0].Message);
+ Assert.Contains("IF EXISTS", issues[0].Message);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithCreateSequenceWithoutIfNotExists_ReturnsIssue()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("CREATE SEQUENCE test_sequence", true, 35)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Single(issues);
+ Assert.Equal(35, issues[0].LineNumber);
+ Assert.Contains("CREATE SEQUENCE", issues[0].Message);
+ Assert.Contains("IF NOT EXISTS", issues[0].Message);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithMultipleIssues_ReturnsAllIssues()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("DROP TABLE users", true, 1),
+ new SqlStatement("CREATE TABLE products (id INT)", true, 2),
+ new SqlStatement("DROP VIEW user_view", true, 3),
+ new SqlStatement("DROP SEQUENCE test_sequence", true, 4),
+ new SqlStatement("CREATE SEQUENCE new_sequence", true, 5)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Equal(5, issues.Count);
+ Assert.Contains(issues, i => i.Message.Contains("DROP TABLE"));
+ Assert.Contains(issues, i => i.Message.Contains("CREATE TABLE"));
+ Assert.Contains(issues, i => i.Message.Contains("DROP VIEW"));
+ Assert.Contains(issues, i => i.Message.Contains("DROP SEQUENCE"));
+ Assert.Contains(issues, i => i.Message.Contains("CREATE SEQUENCE"));
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithEmptyOrNullStatements_ReturnsNoIssues()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("", true),
+ new SqlStatement(" ", true),
+ new SqlStatement(null, true)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements);
+
+ // Assert
+ Assert.Empty(issues);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithCaseInsensitiveStatements_ReturnsIssues()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("drop table Users", true, 1),
+ new SqlStatement("CREATE table Products (id int)", true, 2),
+ new SqlStatement("Drop Schema TestSchema", true, 3)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements).ToList();
+
+ // Assert
+ Assert.Equal(3, issues.Count);
+ }
+
+ [Fact]
+ public void AnalyzeStatements_WithCommentsAndComplexSQL_DoesNotCreateFalsePositives()
+ {
+ // Arrange
+ var statements = new[]
+ {
+ new SqlStatement("-- This is a comment about DROP TABLE\nINSERT INTO users VALUES (1)", true),
+ new SqlStatement("SELECT * FROM users WHERE name = 'CREATE TABLE test'", true),
+ new SqlStatement("UPDATE logs SET message = 'DROP DATABASE occurred' WHERE id = 1", true)
+ };
+
+ // Act
+ var issues = SqlLinter.AnalyzeStatements(statements);
+
+ // Assert
+ Assert.Empty(issues);
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Evolve.Tests/EvolveConfiguration.cs b/test/Evolve.Tests/EvolveConfiguration.cs
index afcce44f..ca3d5d95 100644
--- a/test/Evolve.Tests/EvolveConfiguration.cs
+++ b/test/Evolve.Tests/EvolveConfiguration.cs
@@ -1,9 +1,9 @@
-using System.Collections.Generic;
+using EvolveDb.Configuration;
+using EvolveDb.Migration;
+using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
-using EvolveDb.Configuration;
-using EvolveDb.Migration;
namespace EvolveDb.Tests
{
@@ -41,6 +41,8 @@ public string MetadataTableSchema
public bool RetryRepeatableMigrationsUntilNoError { get; set; }
public TransactionKind TransactionMode { get; set; } = TransactionKind.CommitEach;
public bool SkipNextMigrations { get; set; } = false;
+ public bool EnableSqlLint { get; set; } = false;
+ public SqlLintFailureLevel SqlLintFailureLevel { get; set; } = SqlLintFailureLevel.Warning;
private IMigrationLoader _migrationLoader;
public IMigrationLoader MigrationLoader
diff --git a/test/Evolve.Tests/Integration/SqlLintEndToEndTest.cs b/test/Evolve.Tests/Integration/SqlLintEndToEndTest.cs
new file mode 100644
index 00000000..646dcd4c
--- /dev/null
+++ b/test/Evolve.Tests/Integration/SqlLintEndToEndTest.cs
@@ -0,0 +1,164 @@
+using EvolveDb.Configuration;
+using EvolveDb.Migration;
+using System;
+using System.Collections.Generic;
+using System.IO; // Added for temp directory & file creation
+using System.Linq;
+using Xunit;
+
+namespace EvolveDb.Tests.Integration
+{
+ public class SqlLintEndToEndTest
+ {
+ [Fact]
+ public void SqlLint_WithEnabledWarnings_LogsWarningsAndContinues()
+ {
+ // Arrange
+ var tempDir = CreateUnsafeMigrationDirectory();
+ try
+ {
+ var migrationLoader = new FileMigrationLoader(new EvolveConfiguration
+ {
+ Locations = [tempDir],
+ SqlMigrationPrefix = "V",
+ SqlMigrationSeparator = "__",
+ SqlMigrationSuffix = ".sql"
+ });
+
+ var migrations = migrationLoader.GetMigrations().ToList();
+ var unsafeMigration = migrations.FirstOrDefault(m => m.Name.Contains("unsafe", StringComparison.OrdinalIgnoreCase));
+ Assert.NotNull(unsafeMigration);
+
+ var builder = new TestSqlStatementBuilder();
+ var placeholders = new Dictionary();
+ var logMessages = new List();
+
+ // Act - linting enabled with warnings
+ var statements = builder.LoadSqlStatements(unsafeMigration!, placeholders,
+ enableSqlLint: true,
+ sqlLintFailureLevel: SqlLintFailureLevel.Warning,
+ logAction: msg => logMessages.Add(msg));
+
+ // Assert - statements still processed and warnings logged
+ Assert.Equal(2, statements.Count()); // 2 statements in unsafe file
+ Assert.Equal(2, logMessages.Count); // 2 lint issues => 2 warnings
+ Assert.Contains(logMessages, m => m.Contains("DROP TABLE", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(logMessages, m => m.Contains("CREATE TABLE", StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { /* ignore */ }
+ }
+ }
+ }
+
+ [Fact]
+ public void SqlLint_WithEnabledErrors_ThrowsOnUnsafeMigration()
+ {
+ // Arrange
+ var tempDir = CreateUnsafeMigrationDirectory();
+ try
+ {
+ var migrationLoader = new FileMigrationLoader(new EvolveConfiguration
+ {
+ Locations = [tempDir],
+ SqlMigrationPrefix = "V",
+ SqlMigrationSeparator = "__",
+ SqlMigrationSuffix = ".sql"
+ });
+
+ var migrations = migrationLoader.GetMigrations().ToList();
+ var unsafeMigration = migrations.FirstOrDefault(m => m.Name.Contains("unsafe", StringComparison.OrdinalIgnoreCase));
+ Assert.NotNull(unsafeMigration);
+
+ var builder = new TestSqlStatementBuilder();
+ var placeholders = new Dictionary();
+
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ builder.LoadSqlStatements(unsafeMigration!, placeholders,
+ enableSqlLint: true,
+ sqlLintFailureLevel: SqlLintFailureLevel.Error));
+
+ Assert.Equal(2, exception.Issues.Count);
+ Assert.Contains("DROP TABLE", exception.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("CREATE TABLE", exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { /* ignore */ }
+ }
+ }
+ }
+
+ [Fact]
+ public void SqlLint_WithDisabled_ProcessesAllMigrationsWithoutLinting()
+ {
+ // Arrange
+ var tempDir = CreateUnsafeMigrationDirectory();
+ try
+ {
+ var migrationLoader = new FileMigrationLoader(new EvolveConfiguration
+ {
+ Locations = [tempDir],
+ SqlMigrationPrefix = "V",
+ SqlMigrationSeparator = "__",
+ SqlMigrationSuffix = ".sql"
+ });
+
+ var migrations = migrationLoader.GetMigrations().ToList();
+ var unsafeMigration = migrations.FirstOrDefault(m => m.Name.Contains("unsafe", StringComparison.OrdinalIgnoreCase));
+ Assert.NotNull(unsafeMigration);
+
+ var builder = new TestSqlStatementBuilder();
+ var placeholders = new Dictionary();
+
+ // Act - Linting disabled
+ var statements = builder.LoadSqlStatements(unsafeMigration!, placeholders,
+ enableSqlLint: false,
+ sqlLintFailureLevel: SqlLintFailureLevel.Error);
+
+ // Assert - Should process without errors even with unsafe SQL
+ Assert.Equal(2, statements.Count());
+ }
+ finally
+ {
+ if (Directory.Exists(tempDir))
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { /* ignore */ }
+ }
+ }
+ }
+
+ private static string CreateUnsafeMigrationDirectory()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), "evolve_sql_lint_tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ var migrationPath = Path.Combine(tempDir, "V1__unsafe.sql");
+ // Two unsafe statements: missing IF EXISTS / IF NOT EXISTS protections
+ File.WriteAllText(migrationPath, "DROP TABLE users;CREATE TABLE users (id INT);");
+ return tempDir;
+ }
+
+ private class TestSqlStatementBuilder : EvolveDb.Dialect.SqlStatementBuilderBase
+ {
+ public override string BatchDelimiter => ";";
+
+ protected override IEnumerable Parse(string sqlScript, bool transactionEnabled)
+ {
+ if (string.IsNullOrWhiteSpace(sqlScript))
+ return new List();
+
+ var statements = sqlScript.Split(';', StringSplitOptions.RemoveEmptyEntries);
+ var lineNumber = 1;
+
+ return statements.Select(stmt => new EvolveDb.Dialect.SqlStatement(stmt.Trim(), transactionEnabled, lineNumber++));
+ }
+ }
+ }
+}
\ No newline at end of file