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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions KustoSchemaTools.Tests/Changes/ClusterGroupingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using KustoSchemaTools.Changes;
using KustoSchemaTools.Model;

namespace KustoSchemaTools.Tests.Changes
{
public class ClusterGroupingTests
{
[Fact]
public void BuildClusterFingerprint_IdenticalChanges_ProduceSameFingerprint()
{
var changes1 = CreateSampleChanges("## Table1\nSome diff");
var changes2 = CreateSampleChanges("## Table1\nSome diff");
var comments = new List<Comment>();

var fp1 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes1, comments, true);
var fp2 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes2, comments, true);

Assert.Equal(fp1, fp2);
}

[Fact]
public void BuildClusterFingerprint_DifferentMarkdown_ProduceDifferentFingerprints()
{
var changes1 = CreateSampleChanges("## Table1\nDiff A");
var changes2 = CreateSampleChanges("## Table1\nDiff B");
var comments = new List<Comment>();

var fp1 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes1, comments, true);
var fp2 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes2, comments, true);

Assert.NotEqual(fp1, fp2);
}

[Fact]
public void BuildClusterFingerprint_DifferentValidity_ProduceDifferentFingerprints()
{
var changes = CreateSampleChanges("## Table1\nSame diff");
var comments = new List<Comment>();

var fp1 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes, comments, true);
var fp2 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes, comments, false);

Assert.NotEqual(fp1, fp2);
}

[Fact]
public void BuildClusterFingerprint_DifferentComments_ProduceDifferentFingerprints()
{
var changes = CreateSampleChanges("## Table1\nSame diff");
var comments1 = new List<Comment>
{
new Comment { Kind = CommentKind.Warning, Text = "Warning 1", FailsRollout = false }
};
var comments2 = new List<Comment>
{
new Comment { Kind = CommentKind.Caution, Text = "Caution 1", FailsRollout = true }
};

var fp1 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes, comments1, true);
var fp2 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes, comments2, true);

Assert.NotEqual(fp1, fp2);
}

[Fact]
public void BuildClusterFingerprint_EmptyChanges_ProduceSameFingerprint()
{
var changes = new List<IChange>();
var comments = new List<Comment>();

var fp1 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes, comments, true);
var fp2 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes, comments, true);

Assert.Equal(fp1, fp2);
}

[Fact]
public void BuildClusterFingerprint_CommentsInDifferentOrder_ProduceSameFingerprint()
{
var changes = CreateSampleChanges("## Table1\nSame diff");
var comments1 = new List<Comment>
{
new Comment { Kind = CommentKind.Warning, Text = "A", FailsRollout = false },
new Comment { Kind = CommentKind.Note, Text = "B", FailsRollout = false }
};
var comments2 = new List<Comment>
{
new Comment { Kind = CommentKind.Note, Text = "B", FailsRollout = false },
new Comment { Kind = CommentKind.Warning, Text = "A", FailsRollout = false }
};

var fp1 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes, comments1, true);
var fp2 = KustoSchemaHandler<Database>.BuildClusterFingerprint(changes, comments2, true);

Assert.Equal(fp1, fp2);
}

private static List<IChange> CreateSampleChanges(string markdown)
{
return new List<IChange>
{
new FakeChange(markdown)
};
}

private class FakeChange : IChange
{
public FakeChange(string markdown)
{
Markdown = markdown;
}

public string EntityType => "Test";
public string Entity => "TestEntity";
public List<DatabaseScriptContainer> Scripts => new List<DatabaseScriptContainer>();
public string Markdown { get; }
public Comment Comment { get; set; }
}
}
}
185 changes: 185 additions & 0 deletions KustoSchemaTools.Tests/Changes/ColumnDiffHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using KustoSchemaTools.Changes;

namespace KustoSchemaTools.Tests.Changes
{
public class ColumnDiffHelperTests
{
[Fact]
public void BuildColumnDiff_WithAddedColumns_ShowsAdditions()
{
var oldColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "timestamp", "datetime" }
};
var newColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "timestamp", "datetime" },
{ "new_col", "long" }
};

var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);

Assert.NotNull(result);
Assert.Contains("+ new_col: long", result);
Assert.DoesNotContain("id", result);
Assert.DoesNotContain("timestamp", result);
}

[Fact]
public void BuildColumnDiff_WithTypeChange_ShowsTypeChange()
{
var oldColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "count", "int" }
};
var newColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "count", "long" }
};

var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);

Assert.NotNull(result);
Assert.Contains("! count: int → long", result);
Assert.DoesNotContain("id", result);
}

[Fact]
public void BuildColumnDiff_WithRemovedColumns_ShowsInformationalNote()
{
var oldColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "old_col", "string" },
{ "timestamp", "datetime" }
};
var newColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "timestamp", "datetime" }
};

var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);

Assert.NotNull(result);
Assert.Contains("old_col: string (live only)", result);
Assert.Contains(".create-merge", result);
Assert.DoesNotContain("+ ", result);
}

[Fact]
public void BuildColumnDiff_WithNoChanges_ReturnsNull()
{
var oldColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "timestamp", "datetime" }
};
var newColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "timestamp", "datetime" }
};

var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);

Assert.Null(result);
}

[Fact]
public void BuildColumnDiff_WithNullOldColumns_TreatsAsNewTable()
{
var newColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "timestamp", "datetime" }
};

var result = ColumnDiffHelper.BuildColumnDiff(null, newColumns);

Assert.NotNull(result);
Assert.Contains("+ id: string", result);
Assert.Contains("+ timestamp: datetime", result);
}

[Fact]
public void BuildColumnDiff_WithNullNewColumns_ReturnsNull()
{
var oldColumns = new Dictionary<string, string>
{
{ "id", "string" }
};

var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, null);

Assert.Null(result);
}

[Fact]
public void BuildColumnDiff_WithMixedChanges_ShowsAllCategories()
{
var oldColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "count", "int" },
{ "removed_col", "string" }
};
var newColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "count", "long" },
{ "new_col", "datetime" }
};

var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);

Assert.NotNull(result);
Assert.Contains("+ new_col: datetime", result);
Assert.Contains("! count: int → long", result);
Assert.Contains("removed_col: string (live only)", result);
}

[Fact]
public void BuildColumnDiff_CaseInsensitiveTypeComparison_IgnoresCaseDifference()
{
var oldColumns = new Dictionary<string, string>
{
{ "id", "String" }
};
var newColumns = new Dictionary<string, string>
{
{ "id", "string" }
};

var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);

Assert.Null(result);
}

[Fact]
public void BuildColumnDiff_ReorderOnly_ReturnsNull()
{
var oldColumns = new Dictionary<string, string>
{
{ "id", "string" },
{ "timestamp", "datetime" },
{ "count", "long" }
};
// Same columns, different insertion order in the dictionary
var newColumns = new Dictionary<string, string>
{
{ "count", "long" },
{ "id", "string" },
{ "timestamp", "datetime" }
};

var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);

Assert.Null(result);
}
}
}
74 changes: 74 additions & 0 deletions KustoSchemaTools/Changes/ColumnDiffHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using KustoSchemaTools.Model;
using System.Text;

namespace KustoSchemaTools.Changes
{
/// <summary>
/// Generates a human-readable column-level diff for table schema changes.
/// Instead of showing full .create-merge table lines, shows only the columns
/// that were added or had their type changed.
/// </summary>
public static class ColumnDiffHelper
{
/// <summary>
/// Builds a column-level diff between two sets of columns.
/// </summary>
/// <param name="oldColumns">Columns from the live cluster state (may be null for new tables).</param>
/// <param name="newColumns">Columns from the desired YAML state.</param>
/// <returns>
/// A formatted diff string showing added columns and type changes,
/// or null if there are no meaningful column differences.
/// </returns>
public static string? BuildColumnDiff(Dictionary<string, string>? oldColumns, Dictionary<string, string>? newColumns)
{
if (newColumns == null) return null;
oldColumns ??= new Dictionary<string, string>();

var added = newColumns
.Where(c => !oldColumns.ContainsKey(c.Key))
.ToList();

var typeChanged = newColumns
.Where(c => oldColumns.ContainsKey(c.Key) && !string.Equals(oldColumns[c.Key], c.Value, StringComparison.OrdinalIgnoreCase))
.Select(c => new { Name = c.Key, OldType = oldColumns[c.Key], NewType = c.Value })
.ToList();

var removedFromYaml = oldColumns
.Where(c => !newColumns.ContainsKey(c.Key))
Comment on lines +28 to +37
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildColumnDiff enumerates newColumns/oldColumns directly when rendering added, typeChanged, and removedFromYaml. Since these are Dictionary instances, enumeration order can vary depending on how the dictionaries were constructed, which can make the rendered diff (and cluster grouping fingerprint) non-deterministic. Sorting by column name before rendering (and sorting removed columns) would make the output stable across runs/clusters.

Suggested change
.Where(c => !oldColumns.ContainsKey(c.Key))
.ToList();
var typeChanged = newColumns
.Where(c => oldColumns.ContainsKey(c.Key) && !string.Equals(oldColumns[c.Key], c.Value, StringComparison.OrdinalIgnoreCase))
.Select(c => new { Name = c.Key, OldType = oldColumns[c.Key], NewType = c.Value })
.ToList();
var removedFromYaml = oldColumns
.Where(c => !newColumns.ContainsKey(c.Key))
.Where(c => !oldColumns.ContainsKey(c.Key))
.OrderBy(c => c.Key, StringComparer.Ordinal)
.ToList();
var typeChanged = newColumns
.Where(c => oldColumns.ContainsKey(c.Key) && !string.Equals(oldColumns[c.Key], c.Value, StringComparison.OrdinalIgnoreCase))
.OrderBy(c => c.Key, StringComparer.Ordinal)
.Select(c => new { Name = c.Key, OldType = oldColumns[c.Key], NewType = c.Value })
.ToList();
var removedFromYaml = oldColumns
.Where(c => !newColumns.ContainsKey(c.Key))
.OrderBy(c => c.Key, StringComparer.Ordinal)

Copilot uses AI. Check for mistakes.
.ToList();

if (!added.Any() && !typeChanged.Any() && !removedFromYaml.Any())
return null;

var sb = new StringBuilder();
sb.AppendLine("```diff");

foreach (var col in added)
{
sb.AppendLine($"+ {col.Key}: {col.Value}");
}

foreach (var col in typeChanged)
{
sb.AppendLine($"! {col.Name}: {col.OldType} → {col.NewType}");
}

if (removedFromYaml.Any())
{
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine("> **Note**: The following columns exist in the live cluster but not in YAML.");
sb.AppendLine("> `.create-merge` does not remove columns — they will remain on the table.");
sb.AppendLine();
sb.AppendLine("```");
foreach (var col in removedFromYaml)
{
sb.AppendLine($" {col.Key}: {col.Value} (live only)");
}
}

sb.AppendLine("```");
return sb.ToString();
}
}
}
Loading
Loading