From 5f04d14705a2b57ead8dc0589096fc8235665fe4 Mon Sep 17 00:00:00 2001 From: npc-code Date: Mon, 27 Oct 2025 14:12:36 -0400 Subject: [PATCH 1/2] column order validation feature --- .gitignore | 1 + .../DatabaseChangesColumnValidationTests.cs | 201 ++++++++++++++ .../DatabaseChangesValidationFlagTests.cs | 188 +++++++++++++ .../Configuration/ValidationSettingsTests.cs | 133 +++++++++ .../Parser/KustoClusterHandlerTests.cs | 54 ++-- .../Validation/ColumnOrderValidatorTests.cs | 257 ++++++++++++++++++ KustoSchemaTools/Changes/DatabaseChanges.cs | 91 +++++-- .../Configuration/ValidationSettings.cs | 59 ++++ .../Validation/ColumnOrderValidator.cs | 96 +++++++ .../Validation/ValidationResult.cs | 48 ++++ README.md | 72 +++++ 11 files changed, 1155 insertions(+), 45 deletions(-) create mode 100644 KustoSchemaTools.Tests/Changes/DatabaseChangesColumnValidationTests.cs create mode 100644 KustoSchemaTools.Tests/Changes/DatabaseChangesValidationFlagTests.cs create mode 100644 KustoSchemaTools.Tests/Configuration/ValidationSettingsTests.cs create mode 100644 KustoSchemaTools.Tests/Validation/ColumnOrderValidatorTests.cs create mode 100644 KustoSchemaTools/Configuration/ValidationSettings.cs create mode 100644 KustoSchemaTools/Validation/ColumnOrderValidator.cs create mode 100644 KustoSchemaTools/Validation/ValidationResult.cs diff --git a/.gitignore b/.gitignore index 35e77c4..4523416 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ /KustoSchemaTools.Plugins/bin /.vs /KustoSchemaTools.Cli/KustoSchemaTools.Cli.csproj.user + diff --git a/KustoSchemaTools.Tests/Changes/DatabaseChangesColumnValidationTests.cs b/KustoSchemaTools.Tests/Changes/DatabaseChangesColumnValidationTests.cs new file mode 100644 index 0000000..3114cc6 --- /dev/null +++ b/KustoSchemaTools.Tests/Changes/DatabaseChangesColumnValidationTests.cs @@ -0,0 +1,201 @@ +using KustoSchemaTools.Changes; +using KustoSchemaTools.Configuration; +using KustoSchemaTools.Model; +using Microsoft.Extensions.Logging; +using Moq; + +namespace KustoSchemaTools.Tests.Changes +{ + public class DatabaseChangesColumnValidationTests + { + private readonly Mock _loggerMock; + + public DatabaseChangesColumnValidationTests() + { + _loggerMock = new Mock(); + } + + [Fact] + public void GenerateChanges_ValidColumnOrder_NoCommentAttached() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int"), ("NewCol", "bool") })); + var settings = ValidationSettings.WithColumnOrderValidation(); + + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChanges = changes.Where(c => c.EntityType == "Table").ToList(); + Assert.All(tableChanges, change => Assert.Null(change.Comment)); + } + + [Fact] + public void GenerateChanges_InvalidColumnOrder_CautionCommentWithFailsRollout() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); + var settings = ValidationSettings.WithColumnOrderValidation(); + + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.NotNull(tableChange.Comment); + Assert.True(tableChange.Comment.FailsRollout); + Assert.Equal(CommentKind.Caution, tableChange.Comment.Kind); + Assert.Contains("Column order violation", tableChange.Comment.Text); + Assert.Contains("Col2", tableChange.Comment.Text); + Assert.Contains("NewCol", tableChange.Comment.Text); + } + + [Fact] + public void GenerateChanges_NewTable_NoValidationPerformed() + { + // Arrange + var oldDb = new Database { Tables = new Dictionary() }; + var newDb = CreateDatabase(("Table1", new[] { ("NewCol", "bool"), ("Col1", "string") })); + var settings = ValidationSettings.WithColumnOrderValidation(); + + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.Null(tableChange.Comment); + } + + [Fact] + public void GenerateChanges_MultipleTables_OnlyInvalidOnesGetComments() + { + // Arrange + var oldDb = CreateDatabase( + ("Table1", new[] { ("Col1", "string") }), + ("Table2", new[] { ("Col1", "string") }), + ("Table3", new[] { ("Col1", "string") })); + + var newDb = CreateDatabase( + ("Table1", new[] { ("Col1", "string"), ("NewCol", "int") }), // Valid + ("Table2", new[] { ("NewCol", "int"), ("Col1", "string") }), // Invalid + ("Table3", new[] { ("Col1", "string"), ("AnotherCol", "bool") })); // Valid + + // Act + var settings = ValidationSettings.WithColumnOrderValidation(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var table1Change = changes.FirstOrDefault(c => c.Entity == "Table1"); + var table2Change = changes.FirstOrDefault(c => c.Entity == "Table2"); + var table3Change = changes.FirstOrDefault(c => c.Entity == "Table3"); + + Assert.Null(table1Change?.Comment); + Assert.NotNull(table2Change?.Comment); + Assert.True(table2Change.Comment.FailsRollout); + Assert.Null(table3Change?.Comment); + } + + [Fact] + public void GenerateChanges_InvalidColumnOrder_ErrorMessageIncludesColumnNames() + { + // Arrange + var oldDb = CreateDatabase(("EventsTable", new[] { ("EventId", "string"), ("Timestamp", "datetime") })); + var newDb = CreateDatabase(("EventsTable", new[] { ("EventId", "string"), ("NewMetric", "int"), ("Timestamp", "datetime") })); + + // Act + var settings = ValidationSettings.WithColumnOrderValidation(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "EventsTable"); + Assert.NotNull(tableChange?.Comment); + Assert.Contains("NewMetric", tableChange.Comment.Text); + Assert.Contains("Timestamp", tableChange.Comment.Text); + Assert.Contains("EventsTable", tableChange.Comment.Text); + } + + [Fact] + public void GenerateChanges_TableWithNoColumnsChanged_NoComment() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + + // Act + var settings = ValidationSettings.WithColumnOrderValidation(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChanges = changes.Where(c => c.Entity == "Table1").ToList(); + // May have no changes at all if columns are identical + Assert.All(tableChanges, change => Assert.Null(change.Comment)); + } + + [Fact] + public void GenerateChanges_MultipleNewColumnsInMiddle_FailsValidation() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int"), ("Col3", "bool") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol1", "datetime"), ("NewCol2", "long"), ("Col2", "int"), ("Col3", "bool") })); + + // Act + var settings = ValidationSettings.WithColumnOrderValidation(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange?.Comment); + Assert.True(tableChange.Comment.FailsRollout); + Assert.Contains("Col2", tableChange.Comment.Text); + Assert.Contains("Col3", tableChange.Comment.Text); + } + + [Fact] + public void GenerateChanges_ValidColumnOrderMultipleNewColumns_NoComment() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int"), ("NewCol1", "bool"), ("NewCol2", "datetime"), ("NewCol3", "long") })); + + // Act + var settings = ValidationSettings.WithColumnOrderValidation(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + if (tableChange != null) + { + Assert.Null(tableChange.Comment); + } + } + + private static Database CreateDatabase(params (string TableName, (string Name, string Type)[] Columns)[] tables) + { + var database = new Database + { + Tables = new Dictionary() + }; + + foreach (var table in tables) + { + var tableObj = new Table + { + Columns = new Dictionary() + }; + + foreach (var column in table.Columns) + { + tableObj.Columns[column.Name] = column.Type; + } + + database.Tables[table.TableName] = tableObj; + } + + return database; + } + } +} diff --git a/KustoSchemaTools.Tests/Changes/DatabaseChangesValidationFlagTests.cs b/KustoSchemaTools.Tests/Changes/DatabaseChangesValidationFlagTests.cs new file mode 100644 index 0000000..7f3c010 --- /dev/null +++ b/KustoSchemaTools.Tests/Changes/DatabaseChangesValidationFlagTests.cs @@ -0,0 +1,188 @@ +using KustoSchemaTools.Changes; +using KustoSchemaTools.Configuration; +using KustoSchemaTools.Model; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace KustoSchemaTools.Tests.Changes +{ + public class DatabaseChangesValidationFlagTests + { + private readonly Mock _loggerMock; + + public DatabaseChangesValidationFlagTests() + { + _loggerMock = new Mock(); + } + + [Fact] + public void GenerateChanges_WithoutValidationSettings_DoesNotApplyValidation() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); + + // Act - No validation settings provided (null) + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.Null(tableChange.Comment); // No validation comment should be attached + } + + [Fact] + public void GenerateChanges_WithValidationDisabled_DoesNotApplyValidation() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); + var settings = new ValidationSettings { EnableColumnOrderValidation = false }; + + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.Null(tableChange.Comment); // No validation comment should be attached + } + + [Fact] + public void GenerateChanges_WithValidationEnabled_AppliesValidation() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); + var settings = new ValidationSettings { EnableColumnOrderValidation = true }; + + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.NotNull(tableChange.Comment); // Validation comment should be attached + Assert.True(tableChange.Comment.FailsRollout); + Assert.Contains("Column order violation", tableChange.Comment.Text); + } + + [Fact] + public void GenerateChanges_WithValidationEnabledButValidColumnOrder_NoComment() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int"), ("NewCol", "bool") })); + var settings = new ValidationSettings { EnableColumnOrderValidation = true }; + + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + if (tableChange != null) + { + Assert.Null(tableChange.Comment); // No validation comment should be attached for valid order + } + } + + [Fact] + public void GenerateChanges_WithValidationFromEnvironmentVariable_AppliesValidation() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); + var settings = ValidationSettings.FromEnvironment(); + + try + { + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.NotNull(tableChange.Comment); // Validation comment should be attached + Assert.True(tableChange.Comment.FailsRollout); + Assert.Contains("Column order violation", tableChange.Comment.Text); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void GenerateChanges_WithValidationFromEnvironmentVariableDisabled_DoesNotApplyValidation() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "false"); + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); + var settings = ValidationSettings.FromEnvironment(); + + try + { + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.Null(tableChange.Comment); // No validation comment should be attached + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void GenerateChanges_WithConvenienceMethod_EnablesValidation() + { + // Arrange + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); + var settings = ValidationSettings.WithColumnOrderValidation(); + + // Act + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, settings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.NotNull(tableChange.Comment); // Validation comment should be attached + Assert.True(tableChange.Comment.FailsRollout); + Assert.Contains("Column order violation", tableChange.Comment.Text); + } + + private static Database CreateDatabase(params (string TableName, (string Name, string Type)[] Columns)[] tables) + { + var database = new Database + { + Tables = new Dictionary() + }; + + foreach (var table in tables) + { + var tableObj = new Table + { + Columns = new Dictionary() + }; + + foreach (var column in table.Columns) + { + tableObj.Columns[column.Name] = column.Type; + } + + database.Tables[table.TableName] = tableObj; + } + + return database; + } + } +} diff --git a/KustoSchemaTools.Tests/Configuration/ValidationSettingsTests.cs b/KustoSchemaTools.Tests/Configuration/ValidationSettingsTests.cs new file mode 100644 index 0000000..be32d84 --- /dev/null +++ b/KustoSchemaTools.Tests/Configuration/ValidationSettingsTests.cs @@ -0,0 +1,133 @@ +using KustoSchemaTools.Configuration; +using Xunit; + +namespace KustoSchemaTools.Tests.Configuration +{ + public class ValidationSettingsTests + { + [Fact] + public void ValidationSettings_DefaultConstructor_HasValidationDisabled() + { + // Arrange & Act + var settings = new ValidationSettings(); + + // Assert + Assert.False(settings.EnableColumnOrderValidation); + } + + [Fact] + public void WithColumnOrderValidation_ReturnsSettingsWithValidationEnabled() + { + // Arrange & Act + var settings = ValidationSettings.WithColumnOrderValidation(); + + // Assert + Assert.True(settings.EnableColumnOrderValidation); + } + + [Fact] + public void FromEnvironment_WhenEnvironmentVariableNotSet_ReturnsDefaultSettings() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.False(settings.EnableColumnOrderValidation); + } + + [Fact] + public void FromEnvironment_WhenEnvironmentVariableSetToTrue_EnablesValidation() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + + try + { + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.True(settings.EnableColumnOrderValidation); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void FromEnvironment_WhenEnvironmentVariableSetToFalse_KeepsValidationDisabled() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "false"); + + try + { + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.False(settings.EnableColumnOrderValidation); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Theory] + [InlineData("True")] + [InlineData("TRUE")] + [InlineData("1")] + public void FromEnvironment_WhenEnvironmentVariableSetToTruthyValues_EnablesValidation(string value) + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", value); + + try + { + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.True(settings.EnableColumnOrderValidation); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Theory] + [InlineData("False")] + [InlineData("FALSE")] + [InlineData("0")] + [InlineData("invalid")] + [InlineData("")] + public void FromEnvironment_WhenEnvironmentVariableSetToFalsyOrInvalidValues_KeepsValidationDisabled(string value) + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", value); + + try + { + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.False(settings.EnableColumnOrderValidation); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + } +} diff --git a/KustoSchemaTools.Tests/Parser/KustoClusterHandlerTests.cs b/KustoSchemaTools.Tests/Parser/KustoClusterHandlerTests.cs index b82c7a4..ef53963 100644 --- a/KustoSchemaTools.Tests/Parser/KustoClusterHandlerTests.cs +++ b/KustoSchemaTools.Tests/Parser/KustoClusterHandlerTests.cs @@ -17,7 +17,7 @@ public KustoClusterHandlerTests() { _loggerMock = new Mock>(); _adminClientMock = new Mock(); - + _handler = new KustoClusterHandler( _adminClientMock.Object, _loggerMock.Object, @@ -31,14 +31,14 @@ public async Task WriteAsync_WithEmptyChangeSet_ReturnsEmptyResult() { // Arrange var changeSet = new ClusterChangeSet("test-cluster", new Cluster(), new Cluster()); - + // Act var result = await _handler.WriteAsync(changeSet); - + // Assert Assert.NotNull(result); Assert.Empty(result); - + // Verify no commands were executed _adminClientMock.Verify(x => x.ExecuteControlCommandAsync( It.IsAny(), @@ -66,7 +66,7 @@ public async Task WriteAsync_WithInvalidScripts_SkipsInvalidScripts() // Assert Assert.NotNull(result); - + // Verify only valid scripts were included in the execution _adminClientMock.Verify(x => x.ExecuteControlCommandAsync( "", @@ -94,7 +94,7 @@ public async Task WriteAsync_WithNegativeOrderScripts_SkipsNegativeOrderScripts( // Assert Assert.NotNull(result); - + // Verify negative order scripts were excluded _adminClientMock.Verify(x => x.ExecuteControlCommandAsync( "", @@ -123,13 +123,13 @@ public async Task WriteAsync_WithMultipleValidScripts_ExecutesInCorrectOrder() // Assert Assert.NotNull(result); - + // Verify scripts were executed in order (script0, script1, script2) _adminClientMock.Verify(x => x.ExecuteControlCommandAsync( "", - It.Is(cmd => - cmd.Contains("script0") && - cmd.Contains("script1") && + It.Is(cmd => + cmd.Contains("script0") && + cmd.Contains("script1") && cmd.Contains("script2") && cmd.IndexOf("script0") < cmd.IndexOf("script1") && cmd.IndexOf("script1") < cmd.IndexOf("script2")), @@ -156,11 +156,11 @@ public async Task WriteAsync_WithValidScripts_GeneratesCorrectClusterScript() // Assert Assert.NotNull(result); - + // Verify the correct cluster script format was generated _adminClientMock.Verify(x => x.ExecuteControlCommandAsync( "", - It.Is(cmd => + It.Is(cmd => cmd.StartsWith(".execute cluster script with(ContinueOnErrors = true) <|") && cmd.Contains(".alter cluster policy capacity") && cmd.Contains(".show cluster policy capacity")), @@ -190,11 +190,11 @@ public async Task WriteAsync_WithMixedValidityAndOrder_FiltersCorrectly() // Assert Assert.NotNull(result); - + // Verify only script3 and script5 were included _adminClientMock.Verify(x => x.ExecuteControlCommandAsync( "", - It.Is(cmd => + It.Is(cmd => cmd.Contains("script3") && cmd.Contains("script5") && !cmd.Contains("script1") && @@ -260,7 +260,7 @@ public async Task LoadAsync_WithCapacityPolicy_ReturnsClusterWithPolicy() _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show cluster policy capacity", It.IsAny())) .ReturnsAsync(mockCapacityReader.Object); - + _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show workload_groups", It.IsAny())) .ReturnsAsync(mockWorkloadGroupsReader.Object); @@ -273,7 +273,7 @@ public async Task LoadAsync_WithCapacityPolicy_ReturnsClusterWithPolicy() Assert.Equal("test-cluster", result.Name); Assert.Equal("test.eastus", result.Url); Assert.NotNull(result.CapacityPolicy); - + Assert.NotNull(result.CapacityPolicy.IngestionCapacity); Assert.Equal(500, result.CapacityPolicy.IngestionCapacity.ClusterMaximumConcurrentOperations); Assert.Equal(0.75, result.CapacityPolicy.IngestionCapacity.CoreUtilizationCoefficient); @@ -309,7 +309,7 @@ public async Task LoadAsync_WithNoPolicyData_ReturnsClusterWithoutPolicy() _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show cluster policy capacity", It.IsAny())) .ReturnsAsync(mockCapacityReader.Object); - + _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show workload_groups", It.IsAny())) .ReturnsAsync(mockWorkloadGroupsReader.Object); @@ -350,7 +350,7 @@ public async Task LoadAsync_WithEmptyPolicyJson_ReturnsClusterWithoutPolicy() _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show cluster policy capacity", It.IsAny())) .ReturnsAsync(mockCapacityReader.Object); - + _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show workload_groups", It.IsAny())) .ReturnsAsync(mockWorkloadGroupsReader.Object); @@ -383,7 +383,7 @@ public async Task LoadAsync_WithNullPolicyJson_ReturnsClusterWithoutPolicy() mockCapacityReader.SetupSequence(x => x.Read()) .Returns(true) // First call returns true (data available) .Returns(false); // Second call returns false (no more data) - mockCapacityReader.Setup(x => x["Policy"]).Returns(null); // Null policy + mockCapacityReader.Setup(x => x["Policy"]).Returns(null!); // Null policy var mockWorkloadGroupsReader = new Mock(); mockWorkloadGroupsReader.Setup(x => x.Read()).Returns(false); // No workload groups @@ -391,7 +391,7 @@ public async Task LoadAsync_WithNullPolicyJson_ReturnsClusterWithoutPolicy() _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show cluster policy capacity", It.IsAny())) .ReturnsAsync(mockCapacityReader.Object); - + _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show workload_groups", It.IsAny())) .ReturnsAsync(mockWorkloadGroupsReader.Object); @@ -473,7 +473,7 @@ public async Task LoadAsync_WithWorkloadGroups_ReturnsClusterWithWorkloadGroups( _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show cluster policy capacity", It.IsAny())) .ReturnsAsync(mockCapacityReader.Object); - + _adminClientMock .Setup(x => x.ExecuteControlCommandAsync("", ".show workload_groups", It.IsAny())) .ReturnsAsync(mockWorkloadGroupsReader.Object); @@ -486,11 +486,11 @@ public async Task LoadAsync_WithWorkloadGroups_ReturnsClusterWithWorkloadGroups( Assert.Equal("test-cluster", result.Name); Assert.Equal("test.eastus", result.Url); Assert.Null(result.CapacityPolicy); - + // Verify workload groups were loaded Assert.NotNull(result.WorkloadGroups); Assert.Equal(2, result.WorkloadGroups.Count); - + var defaultGroup = result.WorkloadGroups.FirstOrDefault(wg => wg.WorkloadGroupName == "default"); Assert.NotNull(defaultGroup); Assert.NotNull(defaultGroup.WorkloadGroupPolicy); @@ -522,20 +522,20 @@ public async Task LoadAsync_WithWorkloadGroups_ReturnsClusterWithWorkloadGroups( private ClusterChangeSet CreateChangeSetWithScripts(DatabaseScriptContainer[] scripts) { var changeSet = new ClusterChangeSet("test-cluster", new Cluster(), new Cluster()); - + // Create a mock change that contains the scripts var mockChange = new Mock(); mockChange.Setup(x => x.Scripts).Returns(scripts.ToList()); - + changeSet.Changes.Add(mockChange.Object); - + return changeSet; } private Mock CreateMockDataReader() { var mockReader = new Mock(); - + // Make Read() return false to simulate no data mockReader.Setup(x => x.Read()).Returns(false); diff --git a/KustoSchemaTools.Tests/Validation/ColumnOrderValidatorTests.cs b/KustoSchemaTools.Tests/Validation/ColumnOrderValidatorTests.cs new file mode 100644 index 0000000..f541cad --- /dev/null +++ b/KustoSchemaTools.Tests/Validation/ColumnOrderValidatorTests.cs @@ -0,0 +1,257 @@ +using KustoSchemaTools.Changes; +using KustoSchemaTools.Model; +using KustoSchemaTools.Validation; + +namespace KustoSchemaTools.Tests.Validation +{ + public class ColumnOrderValidatorTests + { + private readonly ColumnOrderValidator _validator; + + public ColumnOrderValidatorTests() + { + _validator = new ColumnOrderValidator(); + } + + [Fact] + public void ValidateColumnOrder_IdenticalColumns_ReturnsSuccess() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.True(result.IsValid); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public void ValidateColumnOrder_NewColumnsAppendedAtEnd_ReturnsSuccess() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int"), ("NewCol", "bool")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateColumnOrder_ExistingColumnAfterNewColumn_ReturnsFailure() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.False(result.IsValid); + Assert.NotNull(result.ErrorMessage); + Assert.Contains("Col2", result.ErrorMessage); + Assert.Contains("NewCol", result.ErrorMessage); + Assert.Contains("Column order violation", result.ErrorMessage); + Assert.Equal(CommentKind.Caution, result.Severity); + } + + [Fact] + public void ValidateColumnOrder_NewTableWithNoBaseline_ReturnsSuccess() + { + // Arrange + Table? baselineTable = null; + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateColumnOrder_EmptyProposedColumns_ReturnsSuccess() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string")); + var proposedTable = new Table { Columns = new Dictionary() }; + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateColumnOrder_NullProposedColumns_ReturnsSuccess() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string")); + var proposedTable = new Table { Columns = null! }; + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateColumnOrder_NullProposedTable_ReturnsSuccess() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string")); + Table? proposedTable = null; + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable!, "Table1"); + + // Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateColumnOrder_MultipleExistingColumnsInterspersed_ReturnsFailureWithAllMisplaced() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int"), ("Col3", "bool")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("NewCol1", "datetime"), ("Col2", "int"), ("NewCol2", "long"), ("Col3", "bool")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Col2", result.ErrorMessage); + Assert.Contains("Col3", result.ErrorMessage); + Assert.Contains("NewCol1", result.ErrorMessage); + Assert.Contains("NewCol2", result.ErrorMessage); + } + + [Fact] + public void ValidateColumnOrder_AllColumnsAreNew_ReturnsSuccess() + { + // Arrange + var baselineTable = new Table { Columns = new Dictionary() }; + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateColumnOrder_MultipleNewColumnsAtEnd_ReturnsSuccess() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int"), ("NewCol1", "bool"), ("NewCol2", "datetime"), ("NewCol3", "long")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateColumnOrder_SingleExistingColumnAtVeryEnd_ReturnsFailure() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int"), ("Col3", "bool")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("NewCol1", "datetime"), ("NewCol2", "long"), ("NewCol3", "decimal"), ("Col2", "int"), ("Col3", "bool")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("Col2", result.ErrorMessage); + Assert.Contains("Col3", result.ErrorMessage); + } + + [Fact] + public void ValidateColumnOrder_NoNewColumnsOnlyExisting_ReturnsSuccess() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateColumnOrder_ErrorMessageContainsTableName() + { + // Arrange + var baselineTable = CreateTable("EventsTable", ("EventId", "string"), ("Timestamp", "datetime")); + var proposedTable = CreateTable("EventsTable", ("EventId", "string"), ("NewMetric", "int"), ("Timestamp", "datetime")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "EventsTable"); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("EventsTable", result.ErrorMessage); + } + + [Fact] + public void ValidateColumnOrder_ErrorMessageExplainsKustoBehavior() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.Contains("Kusto preserves column ordinal positions", result.ErrorMessage); + Assert.Contains("ALTER TABLE operations", result.ErrorMessage); + Assert.Contains("update policy validation failures", result.ErrorMessage); + } + + [Fact] + public void ValidateColumnOrder_ErrorMessageProvidesActionGuidance() + { + // Arrange + var baselineTable = CreateTable("Table1", ("Col1", "string"), ("Col2", "int")); + var proposedTable = CreateTable("Table1", ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int")); + + // Act + var result = _validator.ValidateColumnOrder(baselineTable, proposedTable, "Table1"); + + // Assert + Assert.Contains("Action required", result.ErrorMessage); + Assert.Contains("Move all new columns to the end", result.ErrorMessage); + } + + private static Table CreateTable(string name, params (string Name, string Type)[] columns) + { + var table = new Table + { + Columns = new Dictionary() + }; + + foreach (var column in columns) + { + table.Columns[column.Name] = column.Type; + } + + return table; + } + } +} diff --git a/KustoSchemaTools/Changes/DatabaseChanges.cs b/KustoSchemaTools/Changes/DatabaseChanges.cs index 59017ef..53e0630 100644 --- a/KustoSchemaTools/Changes/DatabaseChanges.cs +++ b/KustoSchemaTools/Changes/DatabaseChanges.cs @@ -1,7 +1,9 @@ using Kusto.Cloud.Platform.Utils; using Kusto.Language; +using KustoSchemaTools.Configuration; using KustoSchemaTools.Model; using KustoSchemaTools.Parser; +using KustoSchemaTools.Validation; using Microsoft.Extensions.Logging; using System.Data; using System.Text; @@ -10,7 +12,7 @@ namespace KustoSchemaTools.Changes { public class DatabaseChanges { - public static List GenerateChanges(Database oldState, Database newState, string name, ILogger log) + public static List GenerateChanges(Database oldState, Database newState, string name, ILogger log, ValidationSettings? validationSettings = null) { var result = new List(); @@ -48,25 +50,25 @@ public static List GenerateChanges(Database oldState, Database newState result.AddRange(GenerateDeletions(oldState, newState.Deletions, log)); - result.AddRange(GenerateScriptCompareChanges(oldState, newState, db => db.Tables, nameof(newState.Tables), log, (oldItem, newItem) => oldItem != null || newItem.Columns?.Any() == true)); + result.AddRange(GenerateTableChangesWithValidation(oldState, newState, log, validationSettings)); var mvChanges = GenerateScriptCompareChanges(oldState, newState, db => db.MaterializedViews, nameof(newState.MaterializedViews), log); - foreach(var mvChange in mvChanges) - { - var relevantChange = mvChange.Scripts.FirstOrDefault(itm => itm.Kind== "CreateMaterializedViewAsync"); - if (relevantChange == null) + foreach (var mvChange in mvChanges) + { + var relevantChange = mvChange.Scripts.FirstOrDefault(itm => itm.Kind == "CreateMaterializedViewAsync"); + if (relevantChange == null) continue; var newMv = newState.MaterializedViews[mvChange.Entity]; - var specificCache = (newMv.Kind== "table" ? + var specificCache = (newMv.Kind == "table" ? (newState.Tables.ContainsKey(newMv.Source) ? newState.Tables[newMv.Source].Policies?.HotCache : null) : (newState.MaterializedViews.ContainsKey(newMv.Source) ? newState.MaterializedViews[newMv.Source].Policies?.HotCache : null)) ?? newState.DefaultRetentionAndCache?.HotCache; - if(specificCache != null && specificCache.EndsWith("d") && int.TryParse(specificCache.TrimEnd("d"), out int lookBackInDays) && DateTime.TryParse(newMv.EffectiveDateTime, out var effectiveDateTime)) + if (specificCache != null && specificCache.EndsWith("d") && int.TryParse(specificCache.TrimEnd("d"), out int lookBackInDays) && DateTime.TryParse(newMv.EffectiveDateTime, out var effectiveDateTime)) { - if(DateTime.UtcNow.AddDays(-lookBackInDays) < effectiveDateTime) + if (DateTime.UtcNow.AddDays(-lookBackInDays) < effectiveDateTime) { // Backfill will work var validUntil = effectiveDateTime.AddDays(lookBackInDays); @@ -75,7 +77,7 @@ public static List GenerateChanges(Database oldState, Database newState else { // Backfill will fail - var validUntil = DateTime.UtcNow.Date.AddDays(1-lookBackInDays); + var validUntil = DateTime.UtcNow.Date.AddDays(1 - lookBackInDays); mvChange.Comment = new Comment { FailsRollout = true, Kind = CommentKind.Caution, Text = $"Not all data for the backfill of {mvChange.Entity} is available hot. The backfill will fail! Please set the effective Date of the MV to {validUntil:yyyy-MM-dd} or newer." }; } } @@ -83,7 +85,7 @@ public static List GenerateChanges(Database oldState, Database newState { mvChange.Comment = new Comment { FailsRollout = false, Kind = CommentKind.Warning, Text = $"The conditions for backfilling {mvChange.Entity} couldn't be validated. Please check for errors!" }; } - } + } result.AddRange(mvChanges); result.AddRange(GenerateScriptCompareChanges(oldState, newState, db => db.ContinuousExports, nameof(newState.ContinuousExports), log)); @@ -148,8 +150,8 @@ private static List GeneratePermissionChanges(Database oldState, Databa if (permissionChanges.Any()) { log.LogInformation($"Detected {permissionChanges.Count} permission changes"); - permissionChanges.Insert(0,new Heading("Permissions")); - + permissionChanges.Insert(0, new Heading("Permissions")); + } return permissionChanges; @@ -174,7 +176,7 @@ private static List GenerateEntityGroupChanges(Database oldState, Datab - private static List GenerateScriptCompareChanges(Database oldState, Database newState,Func> entitySelector,string entityName, ILogger log, Func? validator = null) where T: IKustoBaseEntity + private static List GenerateScriptCompareChanges(Database oldState, Database newState, Func> entitySelector, string entityName, ILogger log, Func? validator = null) where T : IKustoBaseEntity { var tmp = new List(); var existing = entitySelector(oldState) ?? new Dictionary(); @@ -186,7 +188,7 @@ private static List GenerateScriptCompareChanges(Database oldState, foreach (var item in newItems) { var existingOldItem = existing.ContainsKey(item.Key) ? existing[item.Key] : default(T); - if(validator != null && !validator(existingOldItem, item.Value)) + if (validator != null && !validator(existingOldItem, item.Value)) { log.LogInformation($"Skipping {entityName} {item.Key} as it failed validation"); continue; @@ -207,7 +209,7 @@ private static List GenerateScriptCompareChanges(Database oldState, tmp = tmp.Where(itm => itm.Scripts?.Any() == true).ToList(); - if(tmp.Count > 0) + if (tmp.Count > 0) { tmp.Insert(0, new Heading(entityName)); } @@ -259,7 +261,7 @@ .. GenerateFollowerCachingChanges(oldState, newState, db => db.MaterializedViews } } - foreach(var script in result.SelectMany(itm => itm.Scripts)) + foreach (var script in result.SelectMany(itm => itm.Scripts)) { var code = KustoCode.Parse(script.Text); var diagnostics = code.GetDiagnostics(); @@ -270,7 +272,7 @@ .. GenerateFollowerCachingChanges(oldState, newState, db => db.MaterializedViews } - private static List GenerateFollowerCachingChanges(FollowerDatabase oldState, FollowerDatabase newState, Func> selector, string type, string kustoType) + private static List GenerateFollowerCachingChanges(FollowerDatabase oldState, FollowerDatabase newState, Func> selector, string type, string kustoType) { var result = new List(); var oldEntities = selector(oldState.Cache); @@ -332,6 +334,59 @@ private static List GenerateFollowerCachingChanges(FollowerDatabase old return result; } + + /// + /// Generates table changes with optional column order validation. + /// Attaches validation errors as comments when column order violations are detected and validation is enabled. + /// + private static List GenerateTableChangesWithValidation( + Database oldState, + Database newState, + ILogger log, + ValidationSettings? validationSettings) + { + var changes = GenerateScriptCompareChanges( + oldState, + newState, + db => db.Tables, + nameof(newState.Tables), + log, + (oldItem, newItem) => oldItem != null || newItem.Columns?.Any() == true); + + // Apply column order validation to table changes if enabled + if (validationSettings?.EnableColumnOrderValidation == true) + { + var validator = new ColumnOrderValidator(); + + foreach (var change in changes) + { + if (change is ScriptCompareChange scriptChange) + { + var tableName = scriptChange.Entity; + var oldTable = oldState?.Tables?.ContainsKey(tableName) == true + ? oldState.Tables[tableName] + : null; + var newTable = newState.Tables[tableName]; + + var validationResult = validator.ValidateColumnOrder(oldTable, newTable, tableName); + + if (!validationResult.IsValid && validationResult.Severity.HasValue && validationResult.ErrorMessage != null) + { + scriptChange.Comment = new Comment + { + FailsRollout = true, + Kind = validationResult.Severity.Value, + Text = validationResult.ErrorMessage + }; + + log.LogWarning($"Column order validation failed for table {tableName}"); + } + } + } + } + + return changes; + } } } diff --git a/KustoSchemaTools/Configuration/ValidationSettings.cs b/KustoSchemaTools/Configuration/ValidationSettings.cs new file mode 100644 index 0000000..e3466c4 --- /dev/null +++ b/KustoSchemaTools/Configuration/ValidationSettings.cs @@ -0,0 +1,59 @@ +using System; + +namespace KustoSchemaTools.Configuration +{ + /// + /// Configuration settings for validation features in KustoSchemaTools. + /// + public class ValidationSettings + { + /// + /// Gets or sets whether column order validation is enabled. + /// Default is false to preserve existing behavior. + /// + public bool EnableColumnOrderValidation { get; set; } = false; + + /// + /// Creates ValidationSettings from environment variables. + /// + /// A ValidationSettings instance configured from environment variables. + public static ValidationSettings FromEnvironment() + { + var settings = new ValidationSettings(); + + // Check for KUSTO_ENABLE_COLUMN_VALIDATION environment variable + var enableColumnValidation = Environment.GetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION"); + if (!string.IsNullOrEmpty(enableColumnValidation)) + { + // Try standard boolean parsing first + if (bool.TryParse(enableColumnValidation, out bool enable)) + { + settings.EnableColumnOrderValidation = enable; + } + // Also accept "1" as true and "0" as false + else if (enableColumnValidation == "1") + { + settings.EnableColumnOrderValidation = true; + } + else if (enableColumnValidation == "0") + { + settings.EnableColumnOrderValidation = false; + } + } + + return settings; + } + + /// + /// Creates ValidationSettings with column order validation enabled. + /// + /// A ValidationSettings instance with column order validation enabled. + public static ValidationSettings WithColumnOrderValidation() + { + return new ValidationSettings + { + EnableColumnOrderValidation = true + }; + } + } +} diff --git a/KustoSchemaTools/Validation/ColumnOrderValidator.cs b/KustoSchemaTools/Validation/ColumnOrderValidator.cs new file mode 100644 index 0000000..e64d936 --- /dev/null +++ b/KustoSchemaTools/Validation/ColumnOrderValidator.cs @@ -0,0 +1,96 @@ +using KustoSchemaTools.Changes; +using KustoSchemaTools.Model; + +namespace KustoSchemaTools.Validation +{ + /// + /// Validates that new columns in table definitions are appended at the end + /// to prevent update policy failures from column ordinal position changes. + /// + public class ColumnOrderValidator + { + /// + /// Validates that new columns are only appended to the end of the column list. + /// + /// The existing table definition, or null if this is a new table. + /// The proposed table definition to validate. + /// The name of the table being validated. + /// A ValidationResult indicating success or failure with detailed error information. + public ValidationResult ValidateColumnOrder(Table? baselineTable, Table proposedTable, string tableName) + { + if (proposedTable == null) + { + return ValidationResult.Success(); + } + + if (proposedTable.Columns == null || proposedTable.Columns.Count == 0) + { + return ValidationResult.Success(); + } + + // New tables can have any column order + if (baselineTable == null || baselineTable.Columns == null || baselineTable.Columns.Count == 0) + { + return ValidationResult.Success(); + } + + return ValidateColumnOrder( + baselineTable.Columns, + proposedTable.Columns, + tableName); + } + + /// + /// Validates column order when both baseline and proposed columns exist. + /// + private ValidationResult ValidateColumnOrder( + Dictionary baselineColumns, + Dictionary proposedColumns, + string tableName) + { + var baselineColumnNames = baselineColumns.Keys.ToHashSet(); + var proposedColumnList = proposedColumns.Keys.ToList(); + + // Find all new columns + var newColumns = proposedColumnList + .Where(name => !baselineColumnNames.Contains(name)) + .ToList(); + + if (newColumns.Count == 0) + { + return ValidationResult.Success(); + } + + // Find the position of the first new column + int firstNewColumnIndex = proposedColumnList.FindIndex(name => newColumns.Contains(name)); + + // Check if any existing columns appear after the first new column + var misplacedColumns = new List(); + for (int i = firstNewColumnIndex + 1; i < proposedColumnList.Count; i++) + { + string columnName = proposedColumnList[i]; + if (baselineColumnNames.Contains(columnName)) + { + misplacedColumns.Add(columnName); + } + } + + if (misplacedColumns.Count > 0) + { + string newColumnsText = string.Join(", ", newColumns); + string misplacedColumnsText = string.Join(", ", misplacedColumns); + + string errorMessage = $"Column order violation detected in table '{tableName}'. " + + $"New columns must be appended to the end of the table definition. " + + $"Found existing columns ({misplacedColumnsText}) positioned after new columns ({newColumnsText}). " + + $"Kusto preserves column ordinal positions after ALTER TABLE operations, which will cause " + + $"update policy validation failures if columns are inserted in the middle. " + + $"Action required: Move all new columns to the end of the columns list."; + + return ValidationResult.Failure(errorMessage, CommentKind.Caution); + } + + return ValidationResult.Success(); + } + } +} diff --git a/KustoSchemaTools/Validation/ValidationResult.cs b/KustoSchemaTools/Validation/ValidationResult.cs new file mode 100644 index 0000000..e903e1a --- /dev/null +++ b/KustoSchemaTools/Validation/ValidationResult.cs @@ -0,0 +1,48 @@ +using KustoSchemaTools.Changes; + +namespace KustoSchemaTools.Validation +{ + /// + /// Represents the result of a validation operation. + /// + public class ValidationResult + { + private ValidationResult(bool isValid, string? errorMessage, CommentKind? severity) + { + IsValid = isValid; + ErrorMessage = errorMessage; + Severity = severity; + } + + /// + /// Gets a value indicating whether the validation passed. + /// + public bool IsValid { get; } + + /// + /// Gets the error message if validation failed, otherwise null. + /// + public string? ErrorMessage { get; } + + /// + /// Gets the severity level of the validation failure, if applicable. + /// + public CommentKind? Severity { get; } + + /// + /// Creates a successful validation result. + /// + public static ValidationResult Success() + { + return new ValidationResult(true, null, null); + } + + /// + /// Creates a failed validation result with the specified error message and severity. + /// + public static ValidationResult Failure(string errorMessage, CommentKind severity) + { + return new ValidationResult(false, errorMessage, severity); + } + } +} diff --git a/README.md b/README.md index c2a4138..ef66950 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,77 @@ The `KustoClusterOrchestrator` coordinates between cluster handlers to manage cl Currently no plugins are supported. The orchestrator expects all cluster configuration in a central file. +## Validation Features + +### Column Order Validation + +When modifying table schemas, the system can optionally validate that new columns are appended to the end of the column definition. This validation prevents update policy failures that occur when Kusto preserves column ordinal positions after ALTER TABLE operations. + +**⚠️ Note**: Column order validation is **disabled by default** to preserve existing behavior. Enable it explicitly when needed. + +**Validation Rules:** + +* New columns added at the end of existing columns: Pass +* New table creation with any column order: Pass (no baseline to compare) +* Existing columns positioned after new columns: Fail with detailed error +* Reordering of existing columns: Fail + +**Error Handling:** + +Validation failures appear in the diff output as CAUTION-level comments with: +- Description of the violation +- Names of affected columns (new and misplaced) +- Technical explanation of why this matters +- Required remediation steps + +The validation failure will block deployment (FailsRollout=true). + +**Enabling Validation:** + +There are several ways to enable column order validation: + +1. **Environment Variable** (recommended for CI/CD): + ```bash + export KUSTO_ENABLE_COLUMN_VALIDATION=true + ``` + +2. **Programmatically**: + ```csharp + // Enable validation via settings + var settings = ValidationSettings.WithColumnOrderValidation(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "MyDB", logger, settings); + + // Or from environment variables + var settings = ValidationSettings.FromEnvironment(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "MyDB", logger, settings); + ``` + +3. **Default behavior** (validation disabled): + ```csharp + // This will NOT apply column order validation + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "MyDB", logger); + ``` + +**Example Scenarios:** + +Invalid configuration (existing column after new column): + +```yaml +Columns: + ExistingColumn1: string + NewColumn: int # New column inserted + ExistingColumn2: bool # Existing column - causes validation failure +``` + +Valid configuration (new columns appended): + +```yaml +Columns: + ExistingColumn1: string + ExistingColumn2: bool + NewColumn: int # New column appended at end +``` + ## Supported Features Currently following features are supported: @@ -84,6 +155,7 @@ Currently following features are supported: * Default Hot Cache * Tables * Columns + * Column Order Validation * Retention * HotCache * Update Policies From 50a4669a7d871a9c8dd0bdbbf9340b4a9997bfc0 Mon Sep 17 00:00:00 2001 From: npc-code Date: Mon, 3 Nov 2025 09:16:14 -0500 Subject: [PATCH 2/2] more tests. properly passing the env var down to enable validation. removed redundant validation check at writer level --- .../ColumnOrderValidationEndToEndTests.cs | 248 ++++++++++++++++++ .../ColumnOrderValidationIntegrationTests.cs | 192 ++++++++++++++ ...SchemaHandlerValidationIntegrationTests.cs | 109 ++++++++ KustoSchemaTools/KustoSchemaHandler.cs | 20 +- .../KustoWriter/DefaultDatabaseWriter.cs | 1 + 5 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 KustoSchemaTools.Tests/Integration/ColumnOrderValidationEndToEndTests.cs create mode 100644 KustoSchemaTools.Tests/Integration/ColumnOrderValidationIntegrationTests.cs create mode 100644 KustoSchemaTools.Tests/Integration/KustoSchemaHandlerValidationIntegrationTests.cs diff --git a/KustoSchemaTools.Tests/Integration/ColumnOrderValidationEndToEndTests.cs b/KustoSchemaTools.Tests/Integration/ColumnOrderValidationEndToEndTests.cs new file mode 100644 index 0000000..bd82593 --- /dev/null +++ b/KustoSchemaTools.Tests/Integration/ColumnOrderValidationEndToEndTests.cs @@ -0,0 +1,248 @@ +using KustoSchemaTools.Changes; +using KustoSchemaTools.Configuration; +using KustoSchemaTools.Model; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace KustoSchemaTools.Tests.Integration +{ + /// + /// End-to-end tests demonstrating the complete column order validation workflow + /// for command-line usage scenarios. + /// + public class ColumnOrderValidationEndToEndTests + { + private readonly Mock _loggerMock; + + public ColumnOrderValidationEndToEndTests() + { + _loggerMock = new Mock(); + } + + [Fact] + public void EndToEnd_EnvironmentVariableNotSet_ValidationDisabled_AllowsInvalidColumnOrder() + { + // Arrange - Ensure environment variable is not set + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + + try + { + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); // Invalid order + + // Act - Use the same code path as command-line tools + var validationSettings = ValidationSettings.FromEnvironment(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, validationSettings); + + // Simulate checking if deployment should proceed + var comments = changes.Select(itm => itm.Comment).Where(itm => itm != null).ToList(); + var isValid = changes.All(itm => itm.Scripts.All(itm => itm.IsValid != false)) && comments.All(itm => itm.FailsRollout == false); + + // Assert - Should be valid because validation is disabled by default + Assert.False(validationSettings.EnableColumnOrderValidation); + Assert.True(isValid, "When validation is disabled, invalid column order should not block deployment"); + + // No validation comments should be present + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.Null(tableChange.Comment); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void EndToEnd_EnvironmentVariableSetToTrue_ValidationEnabled_BlocksInvalidColumnOrder() + { + // Arrange - Set environment variable to enable validation + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + + try + { + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); // Invalid order + + // Act - Use the same code path as command-line tools + var validationSettings = ValidationSettings.FromEnvironment(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, validationSettings); + + // Simulate checking if deployment should proceed + var comments = changes.Select(itm => itm.Comment).Where(itm => itm != null).ToList(); + var isValid = changes.All(itm => itm.Scripts.All(itm => itm.IsValid != false)) && comments.All(itm => itm.FailsRollout == false); + + // Assert - Should be invalid because validation is enabled and column order is wrong + Assert.True(validationSettings.EnableColumnOrderValidation); + Assert.False(isValid, "When validation is enabled, invalid column order should block deployment"); + + // Should have validation failure comment + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.NotNull(tableChange.Comment); + Assert.True(tableChange.Comment.FailsRollout); + Assert.Contains("Column order violation", tableChange.Comment.Text); + Assert.Equal(CommentKind.Caution, tableChange.Comment.Kind); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void EndToEnd_EnvironmentVariableSetToTrue_ValidationEnabled_AllowsValidColumnOrder() + { + // Arrange - Set environment variable to enable validation + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + + try + { + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int"), ("NewCol", "bool") })); // Valid order - new column at end + + // Act - Use the same code path as command-line tools + var validationSettings = ValidationSettings.FromEnvironment(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, validationSettings); + + // Simulate checking if deployment should proceed + var comments = changes.Select(itm => itm.Comment).Where(itm => itm != null).ToList(); + var isValid = changes.All(itm => itm.Scripts.All(itm => itm.IsValid != false)) && comments.All(itm => itm.FailsRollout == false); + + // Assert - Should be valid because validation is enabled but column order is correct + Assert.True(validationSettings.EnableColumnOrderValidation); + Assert.True(isValid, "When validation is enabled, valid column order should allow deployment"); + + // Should not have validation failure comment + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + if (tableChange.Comment != null) + { + Assert.False(tableChange.Comment.FailsRollout); + Assert.DoesNotContain("Column order violation", tableChange.Comment.Text); + } + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("1")] + public void EndToEnd_VariousEnvVariableFormats_EnableValidation(string envValue) + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", envValue); + + try + { + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); // Invalid order + + // Act + var validationSettings = ValidationSettings.FromEnvironment(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, validationSettings); + + var comments = changes.Select(itm => itm.Comment).Where(itm => itm != null).ToList(); + var isValid = changes.All(itm => itm.Scripts.All(itm => itm.IsValid != false)) && comments.All(itm => itm.FailsRollout == false); + + // Assert - All truthy values should enable validation and block deployment + Assert.True(validationSettings.EnableColumnOrderValidation); + Assert.False(isValid); + + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.NotNull(tableChange.Comment); + Assert.True(tableChange.Comment.FailsRollout); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Theory] + [InlineData("false")] + [InlineData("FALSE")] + [InlineData("0")] + [InlineData("invalid")] + [InlineData("")] + public void EndToEnd_VariousEnvVariableFormats_DisableValidation(string envValue) + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", envValue); + + try + { + var oldDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); + var newDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); // Invalid order + + // Act + var validationSettings = ValidationSettings.FromEnvironment(); + var changes = DatabaseChanges.GenerateChanges(oldDb, newDb, "TestDB", _loggerMock.Object, validationSettings); + + var comments = changes.Select(itm => itm.Comment).Where(itm => itm != null).ToList(); + var isValid = changes.All(itm => itm.Scripts.All(itm => itm.IsValid != false)) && comments.All(itm => itm.FailsRollout == false); + + // Assert - All falsy values should disable validation and allow deployment + Assert.False(validationSettings.EnableColumnOrderValidation); + Assert.True(isValid); + + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.Null(tableChange.Comment); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + private static Database CreateDatabase(params (string TableName, (string Name, string Type)[] Columns)[] tables) + { + var database = new Database + { + Name = "TestDB", + Tables = new Dictionary(), + Admins = new List(), + Users = new List(), + Viewers = new List(), + Monitors = new List(), + Ingestors = new List(), + UnrestrictedViewers = new List(), + Functions = new Dictionary(), + MaterializedViews = new Dictionary(), + ContinuousExports = new Dictionary(), + ExternalTables = new Dictionary(), + EntityGroups = new Dictionary>(), + Followers = new Dictionary(), + Deletions = new Deletions(), + Scripts = new List() + }; + + foreach (var (tableName, columns) in tables) + { + var table = new Table + { + Columns = new Dictionary(), + Folder = "", + DocString = "", + Scripts = new List() + }; + + foreach (var (name, type) in columns) + { + table.Columns[name] = type; + } + + database.Tables[tableName] = table; + } + + return database; + } + } +} diff --git a/KustoSchemaTools.Tests/Integration/ColumnOrderValidationIntegrationTests.cs b/KustoSchemaTools.Tests/Integration/ColumnOrderValidationIntegrationTests.cs new file mode 100644 index 0000000..65fadcd --- /dev/null +++ b/KustoSchemaTools.Tests/Integration/ColumnOrderValidationIntegrationTests.cs @@ -0,0 +1,192 @@ +using KustoSchemaTools.Changes; +using KustoSchemaTools.Configuration; +using KustoSchemaTools.Model; +using KustoSchemaTools.Parser.KustoWriter; +using KustoSchemaTools.Parser; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace KustoSchemaTools.Tests.Integration +{ + public class ColumnOrderValidationIntegrationTests + { + private readonly Mock _loggerMock; + + public ColumnOrderValidationIntegrationTests() + { + _loggerMock = new Mock(); + } + + [Fact] + public void ValidationSettings_FromEnvironment_WhenNotSet_ReturnsDisabledSettings() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + + try + { + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.False(settings.EnableColumnOrderValidation); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void ValidationSettings_FromEnvironment_WhenSetToTrue_ReturnsEnabledSettings() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + + try + { + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.True(settings.EnableColumnOrderValidation); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void DatabaseChanges_WithEnvironmentValidationEnabled_BlocksInvalidColumnOrder() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + + try + { + var sourceDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); // Invalid order + var targetDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); // Original state + + var validationSettings = ValidationSettings.FromEnvironment(); + + // Act + var changes = DatabaseChanges.GenerateChanges(targetDb, sourceDb, "TestDB", _loggerMock.Object, validationSettings); + + // Assert + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.NotNull(tableChange.Comment); + Assert.True(tableChange.Comment.FailsRollout); + Assert.Contains("Column order violation", tableChange.Comment.Text); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void DatabaseChanges_WithEnvironmentValidationDisabled_AllowsInvalidColumnOrder() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "false"); + + try + { + var sourceDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("NewCol", "bool"), ("Col2", "int") })); // Invalid order + var targetDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); // Original state + + var validationSettings = ValidationSettings.FromEnvironment(); + + // Act + var changes = DatabaseChanges.GenerateChanges(targetDb, sourceDb, "TestDB", _loggerMock.Object, validationSettings); + + // Assert - No validation comment should be attached when validation is disabled + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + Assert.Null(tableChange.Comment); // Should not have validation comment when disabled + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void DatabaseChanges_WithEnvironmentValidationEnabled_AllowsValidColumnOrder() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + + try + { + var sourceDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int"), ("NewCol", "bool") })); // Valid order - new column at end + var targetDb = CreateDatabase(("Table1", new[] { ("Col1", "string"), ("Col2", "int") })); // Original state + + var validationSettings = ValidationSettings.FromEnvironment(); + + // Act + var changes = DatabaseChanges.GenerateChanges(targetDb, sourceDb, "TestDB", _loggerMock.Object, validationSettings); + + // Assert - Should not have validation failure comment for valid column order + var tableChange = changes.FirstOrDefault(c => c.Entity == "Table1"); + Assert.NotNull(tableChange); + // Should not have a validation failure comment (could have other comments, but not validation failure) + if (tableChange.Comment != null) + { + Assert.False(tableChange.Comment.FailsRollout); + Assert.DoesNotContain("Column order violation", tableChange.Comment.Text); + } + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + private static Database CreateDatabase(params (string TableName, (string Name, string Type)[] Columns)[] tables) + { + var database = new Database + { + Name = "TestDB", + Tables = new Dictionary(), + Admins = new List(), + Users = new List(), + Viewers = new List(), + Monitors = new List(), + Ingestors = new List(), + UnrestrictedViewers = new List(), + Functions = new Dictionary(), + MaterializedViews = new Dictionary(), + ContinuousExports = new Dictionary(), + ExternalTables = new Dictionary(), + EntityGroups = new Dictionary>(), + Followers = new Dictionary(), + Deletions = new Deletions(), + Scripts = new List() + }; + + foreach (var (tableName, columns) in tables) + { + var table = new Table + { + Columns = new Dictionary(), + Folder = "", + DocString = "", + Scripts = new List() + }; + + foreach (var (name, type) in columns) + { + table.Columns[name] = type; + } + + database.Tables[tableName] = table; + } + + return database; + } + } +} diff --git a/KustoSchemaTools.Tests/Integration/KustoSchemaHandlerValidationIntegrationTests.cs b/KustoSchemaTools.Tests/Integration/KustoSchemaHandlerValidationIntegrationTests.cs new file mode 100644 index 0000000..5769b3f --- /dev/null +++ b/KustoSchemaTools.Tests/Integration/KustoSchemaHandlerValidationIntegrationTests.cs @@ -0,0 +1,109 @@ +using KustoSchemaTools.Configuration; +using KustoSchemaTools.Model; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using System.IO; + +namespace KustoSchemaTools.Tests.Integration +{ + public class KustoSchemaHandlerValidationIntegrationTests + { + private readonly Mock>> _loggerMock; + + public KustoSchemaHandlerValidationIntegrationTests() + { + _loggerMock = new Mock>>(); + } + + [Fact] + public void KustoSchemaHandler_GenerateDiffMarkdown_WithValidationEnabled_ReportsInvalidAsUndeployable() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + + try + { + // This is a simplified test - in practice, GenerateDiffMarkdown reads from files and connects to Kusto + // Here we're testing the ValidationSettings.FromEnvironment() integration + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.True(settings.EnableColumnOrderValidation, "Environment variable should enable validation"); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void KustoSchemaHandler_Apply_WithValidationEnabled_ShouldCheckValidation() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", "true"); + + try + { + // This test verifies that the Apply method will use ValidationSettings.FromEnvironment() + // The actual Apply method integration would require mocking file system and Kusto connections + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.True(settings.EnableColumnOrderValidation, "Apply method should use environment variable"); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Theory] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("1", true)] + [InlineData("false", false)] + [InlineData("FALSE", false)] + [InlineData("0", false)] + [InlineData("invalid", false)] + [InlineData("", false)] + public void ValidationSettings_FromEnvironment_ParsesAllFormatsCorrectly(string envValue, bool expectedEnabled) + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", envValue); + + try + { + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.Equal(expectedEnabled, settings.EnableColumnOrderValidation); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + + [Fact] + public void ValidationSettings_FromEnvironment_WhenNotSet_DefaultsToDisabled() + { + // Arrange + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + + try + { + // Act + var settings = ValidationSettings.FromEnvironment(); + + // Assert + Assert.False(settings.EnableColumnOrderValidation, "Validation should be disabled by default for backward compatibility"); + } + finally + { + Environment.SetEnvironmentVariable("KUSTO_ENABLE_COLUMN_VALIDATION", null); + } + } + } +} diff --git a/KustoSchemaTools/KustoSchemaHandler.cs b/KustoSchemaTools/KustoSchemaHandler.cs index c59d6e5..c398463 100644 --- a/KustoSchemaTools/KustoSchemaHandler.cs +++ b/KustoSchemaTools/KustoSchemaHandler.cs @@ -1,4 +1,5 @@ using KustoSchemaTools.Changes; +using KustoSchemaTools.Configuration; using KustoSchemaTools.Helpers; using KustoSchemaTools.Model; using KustoSchemaTools.Parser; @@ -42,7 +43,8 @@ public KustoSchemaHandler(ILogger> schemaHandlerLogger, Ya var dbHandler = KustoDatabaseHandlerFactory.Create(cluster.Url, databaseName); var kustoDb = await dbHandler.LoadAsync(); - var changes = DatabaseChanges.GenerateChanges(kustoDb, yamlDb, databaseName, Log); + var validationSettings = ValidationSettings.FromEnvironment(); + var changes = DatabaseChanges.GenerateChanges(kustoDb, yamlDb, databaseName, Log, validationSettings); var comments = changes.Select(itm => itm.Comment).Where(itm => itm != null).ToList(); @@ -139,7 +141,23 @@ await Parallel.ForEachAsync(clusters.Connections, async (cluster, token) => try { Log.LogInformation($"Generating and applying script for {Path.Combine(path, databaseName)} => {cluster}/{databaseName}"); + + // First, check validation before attempting to apply changes var dbHandler = KustoDatabaseHandlerFactory.Create(cluster.Url, databaseName); + var kustoDb = await dbHandler.LoadAsync(); + var validationSettings = ValidationSettings.FromEnvironment(); + var changes = DatabaseChanges.GenerateChanges(kustoDb, yamlDb, databaseName, Log, validationSettings); + + var comments = changes.Select(itm => itm.Comment).Where(itm => itm != null).ToList(); + var isValid = changes.All(itm => itm.Scripts.All(itm => itm.IsValid != false)) && comments.All(itm => itm.FailsRollout == false); + + if (!isValid) + { + var failedComments = comments.Where(itm => itm.FailsRollout).ToList(); + var failureReasons = string.Join("; ", failedComments.Select(c => c.Text)); + throw new InvalidOperationException($"Deployment blocked due to validation failures: {failureReasons}"); + } + await dbHandler.WriteAsync(yamlDb); results.TryAdd(cluster.Url, null!); } diff --git a/KustoSchemaTools/Parser/KustoWriter/DefaultDatabaseWriter.cs b/KustoSchemaTools/Parser/KustoWriter/DefaultDatabaseWriter.cs index 52b8db6..e0edbcb 100644 --- a/KustoSchemaTools/Parser/KustoWriter/DefaultDatabaseWriter.cs +++ b/KustoSchemaTools/Parser/KustoWriter/DefaultDatabaseWriter.cs @@ -13,6 +13,7 @@ public class DefaultDatabaseWriter : IDBEntityWriter public async Task WriteAsync(Database sourceDb, Database targetDb, KustoClient client, ILogger logger) { var changes = DatabaseChanges.GenerateChanges(targetDb, sourceDb, targetDb.Name, logger); + var results = await ApplyChangesToDatabase(targetDb.Name, changes, client, logger); foreach (var result in results)