Skip to content

Commit a6fdd05

Browse files
alex-slynkoCopilot
andcommitted
Add column-level diffing for table schema changes
Instead of showing full .create-merge table lines, show only added/changed/live-only columns. Uses ColumnDiffHelper to compare column dictionaries and render a concise diff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cbd97dc commit a6fdd05

3 files changed

Lines changed: 320 additions & 30 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
using KustoSchemaTools.Changes;
2+
3+
namespace KustoSchemaTools.Tests.Changes
4+
{
5+
public class ColumnDiffHelperTests
6+
{
7+
[Fact]
8+
public void BuildColumnDiff_WithAddedColumns_ShowsAdditions()
9+
{
10+
var oldColumns = new Dictionary<string, string>
11+
{
12+
{ "id", "string" },
13+
{ "timestamp", "datetime" }
14+
};
15+
var newColumns = new Dictionary<string, string>
16+
{
17+
{ "id", "string" },
18+
{ "timestamp", "datetime" },
19+
{ "new_col", "long" }
20+
};
21+
22+
var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);
23+
24+
Assert.NotNull(result);
25+
Assert.Contains("+ new_col: long", result);
26+
Assert.DoesNotContain("id", result);
27+
Assert.DoesNotContain("timestamp", result);
28+
}
29+
30+
[Fact]
31+
public void BuildColumnDiff_WithTypeChange_ShowsTypeChange()
32+
{
33+
var oldColumns = new Dictionary<string, string>
34+
{
35+
{ "id", "string" },
36+
{ "count", "int" }
37+
};
38+
var newColumns = new Dictionary<string, string>
39+
{
40+
{ "id", "string" },
41+
{ "count", "long" }
42+
};
43+
44+
var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);
45+
46+
Assert.NotNull(result);
47+
Assert.Contains("! count: int → long", result);
48+
Assert.DoesNotContain("id", result);
49+
}
50+
51+
[Fact]
52+
public void BuildColumnDiff_WithRemovedColumns_ShowsInformationalNote()
53+
{
54+
var oldColumns = new Dictionary<string, string>
55+
{
56+
{ "id", "string" },
57+
{ "old_col", "string" },
58+
{ "timestamp", "datetime" }
59+
};
60+
var newColumns = new Dictionary<string, string>
61+
{
62+
{ "id", "string" },
63+
{ "timestamp", "datetime" }
64+
};
65+
66+
var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);
67+
68+
Assert.NotNull(result);
69+
Assert.Contains("old_col: string (live only)", result);
70+
Assert.Contains(".create-merge", result);
71+
Assert.DoesNotContain("+ ", result);
72+
}
73+
74+
[Fact]
75+
public void BuildColumnDiff_WithNoChanges_ReturnsNull()
76+
{
77+
var oldColumns = new Dictionary<string, string>
78+
{
79+
{ "id", "string" },
80+
{ "timestamp", "datetime" }
81+
};
82+
var newColumns = new Dictionary<string, string>
83+
{
84+
{ "id", "string" },
85+
{ "timestamp", "datetime" }
86+
};
87+
88+
var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);
89+
90+
Assert.Null(result);
91+
}
92+
93+
[Fact]
94+
public void BuildColumnDiff_WithNullOldColumns_TreatsAsNewTable()
95+
{
96+
var newColumns = new Dictionary<string, string>
97+
{
98+
{ "id", "string" },
99+
{ "timestamp", "datetime" }
100+
};
101+
102+
var result = ColumnDiffHelper.BuildColumnDiff(null, newColumns);
103+
104+
Assert.NotNull(result);
105+
Assert.Contains("+ id: string", result);
106+
Assert.Contains("+ timestamp: datetime", result);
107+
}
108+
109+
[Fact]
110+
public void BuildColumnDiff_WithNullNewColumns_ReturnsNull()
111+
{
112+
var oldColumns = new Dictionary<string, string>
113+
{
114+
{ "id", "string" }
115+
};
116+
117+
var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, null);
118+
119+
Assert.Null(result);
120+
}
121+
122+
[Fact]
123+
public void BuildColumnDiff_WithMixedChanges_ShowsAllCategories()
124+
{
125+
var oldColumns = new Dictionary<string, string>
126+
{
127+
{ "id", "string" },
128+
{ "count", "int" },
129+
{ "removed_col", "string" }
130+
};
131+
var newColumns = new Dictionary<string, string>
132+
{
133+
{ "id", "string" },
134+
{ "count", "long" },
135+
{ "new_col", "datetime" }
136+
};
137+
138+
var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);
139+
140+
Assert.NotNull(result);
141+
Assert.Contains("+ new_col: datetime", result);
142+
Assert.Contains("! count: int → long", result);
143+
Assert.Contains("removed_col: string (live only)", result);
144+
}
145+
146+
[Fact]
147+
public void BuildColumnDiff_CaseInsensitiveTypeComparison_IgnoresCaseDifference()
148+
{
149+
var oldColumns = new Dictionary<string, string>
150+
{
151+
{ "id", "String" }
152+
};
153+
var newColumns = new Dictionary<string, string>
154+
{
155+
{ "id", "string" }
156+
};
157+
158+
var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);
159+
160+
Assert.Null(result);
161+
}
162+
163+
[Fact]
164+
public void BuildColumnDiff_ReorderOnly_ReturnsNull()
165+
{
166+
var oldColumns = new Dictionary<string, string>
167+
{
168+
{ "id", "string" },
169+
{ "timestamp", "datetime" },
170+
{ "count", "long" }
171+
};
172+
// Same columns, different insertion order in the dictionary
173+
var newColumns = new Dictionary<string, string>
174+
{
175+
{ "count", "long" },
176+
{ "id", "string" },
177+
{ "timestamp", "datetime" }
178+
};
179+
180+
var result = ColumnDiffHelper.BuildColumnDiff(oldColumns, newColumns);
181+
182+
Assert.Null(result);
183+
}
184+
}
185+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using KustoSchemaTools.Model;
2+
using System.Text;
3+
4+
namespace KustoSchemaTools.Changes
5+
{
6+
/// <summary>
7+
/// Generates a human-readable column-level diff for table schema changes.
8+
/// Instead of showing full .create-merge table lines, shows only the columns
9+
/// that were added or had their type changed.
10+
/// </summary>
11+
public static class ColumnDiffHelper
12+
{
13+
/// <summary>
14+
/// Builds a column-level diff between two sets of columns.
15+
/// </summary>
16+
/// <param name="oldColumns">Columns from the live cluster state (may be null for new tables).</param>
17+
/// <param name="newColumns">Columns from the desired YAML state.</param>
18+
/// <returns>
19+
/// A formatted diff string showing added columns and type changes,
20+
/// or null if there are no meaningful column differences.
21+
/// </returns>
22+
public static string? BuildColumnDiff(Dictionary<string, string>? oldColumns, Dictionary<string, string>? newColumns)
23+
{
24+
if (newColumns == null) return null;
25+
oldColumns ??= new Dictionary<string, string>();
26+
27+
var added = newColumns
28+
.Where(c => !oldColumns.ContainsKey(c.Key))
29+
.ToList();
30+
31+
var typeChanged = newColumns
32+
.Where(c => oldColumns.ContainsKey(c.Key) && !string.Equals(oldColumns[c.Key], c.Value, StringComparison.OrdinalIgnoreCase))
33+
.Select(c => new { Name = c.Key, OldType = oldColumns[c.Key], NewType = c.Value })
34+
.ToList();
35+
36+
var removedFromYaml = oldColumns
37+
.Where(c => !newColumns.ContainsKey(c.Key))
38+
.ToList();
39+
40+
if (!added.Any() && !typeChanged.Any() && !removedFromYaml.Any())
41+
return null;
42+
43+
var sb = new StringBuilder();
44+
sb.AppendLine("```diff");
45+
46+
foreach (var col in added)
47+
{
48+
sb.AppendLine($"+ {col.Key}: {col.Value}");
49+
}
50+
51+
foreach (var col in typeChanged)
52+
{
53+
sb.AppendLine($"! {col.Name}: {col.OldType}{col.NewType}");
54+
}
55+
56+
if (removedFromYaml.Any())
57+
{
58+
sb.AppendLine("```");
59+
sb.AppendLine();
60+
sb.AppendLine("> **Note**: The following columns exist in the live cluster but not in YAML.");
61+
sb.AppendLine("> `.create-merge` does not remove columns — they will remain on the table.");
62+
sb.AppendLine();
63+
sb.AppendLine("```");
64+
foreach (var col in removedFromYaml)
65+
{
66+
sb.AppendLine($" {col.Key}: {col.Value} (live only)");
67+
}
68+
}
69+
70+
sb.AppendLine("```");
71+
return sb.ToString();
72+
}
73+
}
74+
}

KustoSchemaTools/Changes/ScriptCompareChange.cs

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -77,37 +77,10 @@ private void Init()
7777
sb.AppendLine($"</tr>");
7878
if (before != null)
7979
{
80-
foreach(var c in new [] { new { Change = ChangeType.Deleted, Prefix = "-" }, new { Change = ChangeType.Inserted, Prefix = "+" } })
80+
var columnDiffRendered = TryRenderColumnDiff(change.Kind, sb);
81+
if (!columnDiffRendered)
8182
{
82-
var changeType = c.Change;
83-
if (diff.Lines.Any(itm => itm.Type == changeType))
84-
{
85-
sb.AppendLine("<tr>");
86-
sb.AppendLine($" <td colspan=\"2\">{c.Change}:</td>");
87-
sb.AppendLine($" <td colspan=\"10\"> \n\n```diff ");
88-
89-
var relevantLines = diff.Lines.Where(itm => itm.Type == ChangeType.Unchanged || itm.Type == changeType).OrderBy(itm => itm.Position).ToList();
90-
int last = 0;
91-
for (int i = 0; i < relevantLines.Count; i++)
92-
{
93-
var b = i - 1 > 0 ? relevantLines[i - 1] : null;
94-
var current = relevantLines[i];
95-
var n = i + 1 < relevantLines.Count ? relevantLines[i + 1] : null;
96-
97-
if (current.Type == changeType || b?.Type == changeType || n?.Type == changeType)
98-
{
99-
if(i-last > 1)
100-
{
101-
sb.AppendLine();
102-
}
103-
104-
var p = current.Type == changeType ? c.Prefix : " ";
105-
sb.AppendLine($"{p}{i}:\t{current.Text}");
106-
last = i;
107-
}
108-
}
109-
sb.AppendLine("```\n\n</td></tr>");
110-
}
83+
RenderLineDiff(diff, sb);
11184
}
11285
}
11386
sb.AppendLine("<tr>");
@@ -129,6 +102,64 @@ private void Init()
129102
sb.AppendLine("</table>");
130103
Markdown = sb.ToString();
131104
}
105+
106+
/// <summary>
107+
/// Attempts to render a column-level diff for CreateMergeTable changes.
108+
/// Returns true if a column diff was rendered, false to fall back to line diff.
109+
/// </summary>
110+
private bool TryRenderColumnDiff(string kind, StringBuilder sb)
111+
{
112+
if (kind != "CreateMergeTable") return false;
113+
114+
var oldTable = From as Table;
115+
var newTable = To as Table;
116+
if (newTable?.Columns == null) return false;
117+
118+
var columnDiff = ColumnDiffHelper.BuildColumnDiff(oldTable?.Columns, newTable.Columns);
119+
if (columnDiff == null) return false;
120+
121+
sb.AppendLine("<tr>");
122+
sb.AppendLine(" <td colspan=\"2\">Column changes:</td>");
123+
sb.AppendLine($" <td colspan=\"10\">\n\n{columnDiff}\n\n</td>");
124+
sb.AppendLine("</tr>");
125+
return true;
126+
}
127+
128+
private static void RenderLineDiff(DiffPaneModel diff, StringBuilder sb)
129+
{
130+
foreach (var c in new[] { new { Change = ChangeType.Deleted, Prefix = "-" }, new { Change = ChangeType.Inserted, Prefix = "+" } })
131+
{
132+
var changeType = c.Change;
133+
if (diff.Lines.Any(itm => itm.Type == changeType))
134+
{
135+
sb.AppendLine("<tr>");
136+
sb.AppendLine($" <td colspan=\"2\">{c.Change}:</td>");
137+
sb.AppendLine($" <td colspan=\"10\"> \n\n```diff ");
138+
139+
var relevantLines = diff.Lines.Where(itm => itm.Type == ChangeType.Unchanged || itm.Type == changeType).OrderBy(itm => itm.Position).ToList();
140+
int last = 0;
141+
for (int i = 0; i < relevantLines.Count; i++)
142+
{
143+
var b = i - 1 > 0 ? relevantLines[i - 1] : null;
144+
var current = relevantLines[i];
145+
var n = i + 1 < relevantLines.Count ? relevantLines[i + 1] : null;
146+
147+
if (current.Type == changeType || b?.Type == changeType || n?.Type == changeType)
148+
{
149+
if (i - last > 1)
150+
{
151+
sb.AppendLine();
152+
}
153+
154+
var p = current.Type == changeType ? c.Prefix : " ";
155+
sb.AppendLine($"{p}{i}:\t{current.Text}");
156+
last = i;
157+
}
158+
}
159+
sb.AppendLine("```\n\n</td></tr>");
160+
}
161+
}
162+
}
132163
}
133164

134165
}

0 commit comments

Comments
 (0)