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