Skip to content

Commit 1bd1d3d

Browse files
authored
Merge pull request #99 from zachariahcox/tests
feature: add support for `preformatted` option in yaml function and materialized view representations
2 parents 8df7793 + 4ecb747 commit 1bd1d3d

File tree

12 files changed

+306
-39
lines changed

12 files changed

+306
-39
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
folder: test
2+
docString: Function with comment at end
3+
body: |-
4+
sourceTable
5+
| limit 100
6+
| where IsNotEmpty(EventId) // this is a comment at the end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
folder: test
2+
docString: issues for relevant services, filtered
3+
preformatted: false
4+
body: |-
5+
sourceTable
6+
| where t between(startofday(_startTime)..endofday(_endTime)) or classifier == "somevalue"
7+
// comments
8+
| where repository_id in (table_function(_aaaaaaaaa,_bbbbbbbb,_cccccccc,_eeeeeeeee,_fffffffff) | distinct id) // prefer `in` over `join` for short right columns
9+
| project id
10+
, type
11+
, t
12+
| summarize arg_max(t, *) by id
13+
| lookup (table_function(_aaaaaaaaa,_bbbbbbbb,_cccccccc,_eeeeeeeee,_fffffffff) | distinct id, classifier) on id
14+
| extend type = case(
15+
id == 1, "a",
16+
id == 2, "b",
17+
"other") // comments
18+
| project id, type, classifier
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
folder: test
2+
docString: issues for relevant services, filtered
3+
preformatted: true
4+
body: |-
5+
sourceTable
6+
| where t between(startofday(_startTime)..endofday(_endTime)) or classifier == "somevalue"
7+
// comments
8+
| where repository_id in (table_function(_aaaaaaaaa,_bbbbbbbb,_cccccccc,_eeeeeeeee,_fffffffff) | distinct id) // prefer `in` over `join` for short right columns
9+
| project id
10+
, type
11+
, t
12+
| summarize arg_max(t, *) by id
13+
| lookup (table_function(_aaaaaaaaa,_bbbbbbbb,_cccccccc,_eeeeeeeee,_fffffffff) | distinct id, classifier) on id
14+
| extend type = case(
15+
id == 1, "a",
16+
id == 2, "b",
17+
"other") // comments
18+
| project id, type, classifier
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
folder: test
2+
docString: test function that would change with formatting
3+
body: |-
4+
sourceTable | limit 100
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
source: sourceTable
2+
kind: table
3+
folder: test
4+
retentionAndCachePolicy:
5+
retention: 720d
6+
query: |-
7+
sourceTable
8+
| where type == "a"
9+
| summarize hint.strategy=shuffle active=countif(is_active != true),
10+
archived=countif(is_archived)
11+
by id
12+
, day
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
source: sourceTable
2+
kind: table
3+
folder: test
4+
preformatted: true
5+
retentionAndCachePolicy:
6+
retention: 720d
7+
query: |-
8+
sourceTable
9+
| where type == "a"
10+
| summarize hint.strategy=shuffle active=countif(is_active != true),
11+
archived=countif(is_archived)
12+
by id
13+
, day

KustoSchemaTools.Tests/KustoSchemaTools.Tests.csproj

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,9 @@
2727
<ProjectReference Include="..\KustoSchemaTools\KustoSchemaTools.csproj" />
2828
</ItemGroup>
2929

30+
<!-- Automatically include all files in the DemoData directory -->
3031
<ItemGroup>
31-
<None Update="DemoData\DemoDeployment\clusters.yml">
32-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
33-
</None>
34-
<None Update="DemoData\DemoDeployment\DemoDatabase\database.yml">
35-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
36-
</None>
37-
<None Update="DemoData\DemoDeployment\DemoDatabase\functions\UP.yml">
38-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
39-
</None>
40-
<None Update="DemoData\DemoDeployment\DemoDatabase\tables\sourceTable.yml">
41-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
42-
</None>
43-
<None Update="DemoData\DemoDeployment\DemoDatabase\tables\tableWithUp.yml">
32+
<None Include="DemoData\**\*.*">
4433
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
4534
</None>
4635
</ItemGroup>

KustoSchemaTools.Tests/YamlDatabaseParserTests.cs

Lines changed: 147 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
using KustoSchemaTools.Parser;
33
using KustoSchemaTools.Plugins;
44
using KustoSchemaTools.Model;
5+
using KustoSchemaTools.Changes;
6+
using Kusto.Data;
7+
using System.IO;
58

69
namespace KustoSchemaTools.Tests.Parser
710
{
@@ -17,27 +20,158 @@ public async Task GetDatabase()
1720
var factory = new YamlDatabaseHandlerFactory<Model.Database>()
1821
.WithPlugin(new TablePlugin())
1922
.WithPlugin(new FunctionPlugin())
20-
.WithPlugin(new MaterializedViewsPlugin())
2123
.WithPlugin(new DatabaseCleanup());
2224
var loader = factory.Create(Path.Combine(BasePath, Deployment), Database);
2325

2426
var db = await loader.LoadAsync();
2527

2628
Assert.NotNull(db);
2729
Assert.Equal(2, db.Tables.Count);
28-
Assert.Single(db.Functions);
2930
Assert.Equal(6, db.Functions["UP"].Body.RowLength());
3031
Assert.Equal("DemoDatabase", db.Name);
31-
var policies = db.Tables["sourceTable"].Policies;
32-
Assert.NotNull(policies);
33-
Assert.Equal("120d", policies.Retention);
34-
Assert.Equal("120d", policies.HotCache);
35-
Assert.Equal("Test team", db.Team);
36-
Assert.True(db.Tables["sourceTable"].RestrictedViewAccess);
37-
38-
// these tests do not compile! to be removed in a future PR.
39-
// Assert.Equal("120d", db.Tables["tableWithUp"].RetentionAndCachePolicy.Retention);
40-
// Assert.Equal("120d", db.Tables["sourceTable"].RetentionAndCachePolicy.HotCache);
32+
33+
var st = db.Tables["sourceTable"];
34+
Assert.NotNull(st);
35+
Assert.NotNull(st.Policies);
36+
Assert.True(st.Policies!.RestrictedViewAccess);
37+
Assert.Equal("120d", st.Policies?.HotCache);
38+
39+
var tt = db.Tables["tableWithUp"];
40+
Assert.NotNull(tt);
41+
Assert.NotNull(tt.Policies);
42+
Assert.False(tt.Policies!.RestrictedViewAccess);
43+
Assert.Equal("120d", tt.Policies?.Retention);
44+
}
45+
46+
[Fact]
47+
public async Task VerifyFunctionPreformatted()
48+
{
49+
// WITHOUT the DatabaseCleanup plugin
50+
var factoryWithoutCleanup = new YamlDatabaseHandlerFactory<Model.Database>()
51+
.WithPlugin(new TablePlugin())
52+
.WithPlugin(new FunctionPlugin());
53+
// DatabaseCleanup intentionally omitted
54+
var loaderWithoutCleanup = factoryWithoutCleanup.Create(Path.Combine(BasePath, Deployment), Database);
55+
var dbWithoutCleanup = await loaderWithoutCleanup.LoadAsync();
56+
57+
// with the DatabaseCleanup plugin
58+
var factoryWithCleanup = new YamlDatabaseHandlerFactory<Model.Database>()
59+
.WithPlugin(new TablePlugin())
60+
.WithPlugin(new FunctionPlugin())
61+
.WithPlugin(new MaterializedViewsPlugin())
62+
.WithPlugin(new DatabaseCleanup());
63+
var loaderWithCleanup = factoryWithCleanup.Create(Path.Combine(BasePath, Deployment), Database);
64+
var dbWithCleanup = await loaderWithCleanup.LoadAsync();
65+
66+
// Assert
67+
Assert.NotNull(dbWithCleanup);
68+
Assert.NotNull(dbWithoutCleanup);
69+
Assert.Equal(dbWithCleanup.Functions.Count, dbWithoutCleanup.Functions.Count);
70+
71+
// Verify the UP function has preformatted set to false (default)
72+
var up_withCleanup = dbWithCleanup.Functions["UP"];
73+
var up_withoutCleanup = dbWithoutCleanup.Functions["UP"];
74+
Assert.NotNull(up_withCleanup);
75+
Assert.NotNull(up_withoutCleanup);
76+
Assert.False(up_withCleanup.Preformatted);
77+
Assert.False(up_withoutCleanup.Preformatted);
78+
79+
// this case is simple and formatting has no impact.
80+
Assert.Equal(up_withoutCleanup.Body.RowLength(), up_withCleanup.Body.RowLength());
81+
82+
// Verify the needs_formatting query changed when formatting.
83+
var f_withCleanup = dbWithCleanup.Functions["needs_formatting"];
84+
var f_withoutCleanup = dbWithoutCleanup.Functions["needs_formatting"];
85+
Assert.NotNull(f_withCleanup);
86+
Assert.NotNull(f_withoutCleanup);
87+
Assert.False(f_withCleanup.Preformatted);
88+
Assert.False(f_withoutCleanup.Preformatted);
89+
90+
// preformatted function should have been formatted by DatabaseCleanup
91+
Assert.NotEqual(f_withCleanup.Body, f_withoutCleanup.Body);
92+
93+
// much more complicated function where formatting breaks the query
94+
var complicated_with_cleanup = dbWithCleanup.Functions["complicated"].Body;
95+
var complicated_without_cleanup = dbWithoutCleanup.Functions["complicated"].Body;
96+
Assert.NotEqual(complicated_with_cleanup, complicated_without_cleanup);
97+
98+
var complicated_pf_with_cleanup = dbWithCleanup.Functions["complicated_preformatted"].Body;
99+
var complicated_pf_without_cleanup = dbWithoutCleanup.Functions["complicated_preformatted"].Body;
100+
101+
// preformatted option makes query match non-formatted version
102+
Assert.Equal(complicated_pf_without_cleanup, complicated_pf_with_cleanup);
103+
104+
// preformatted option makes query match non-formatted version
105+
Assert.Equal(complicated_without_cleanup, complicated_pf_with_cleanup);
106+
}
107+
108+
[Fact]
109+
public async Task VerifyMaterializedView()
110+
{
111+
// WITHOUT the DatabaseCleanup plugin
112+
var factoryWithoutCleanup = new YamlDatabaseHandlerFactory<Model.Database>()
113+
.WithPlugin(new TablePlugin())
114+
.WithPlugin(new MaterializedViewsPlugin());
115+
// DatabaseCleanup intentionally omitted
116+
var loaderWithoutCleanup = factoryWithoutCleanup.Create(Path.Combine(BasePath, Deployment), Database);
117+
var dbWithoutCleanup = await loaderWithoutCleanup.LoadAsync();
118+
119+
// with the DatabaseCleanup plugin
120+
var factoryWithCleanup = new YamlDatabaseHandlerFactory<Model.Database>()
121+
.WithPlugin(new TablePlugin())
122+
.WithPlugin(new MaterializedViewsPlugin())
123+
.WithPlugin(new DatabaseCleanup());
124+
var loaderWithCleanup = factoryWithCleanup.Create(Path.Combine(BasePath, Deployment), Database);
125+
var dbWithCleanup = await loaderWithCleanup.LoadAsync();
126+
127+
// Assert
128+
Assert.NotNull(dbWithCleanup);
129+
Assert.NotNull(dbWithoutCleanup);
130+
Assert.Equal(dbWithCleanup.MaterializedViews.Count, dbWithoutCleanup.MaterializedViews.Count);
131+
132+
// basic materialized view tests
133+
void AssertMaterializedView(
134+
string file_name,
135+
bool should_match)
136+
{
137+
var mv_with_cleanup = dbWithCleanup.MaterializedViews[file_name];
138+
var mv_without_cleanup = dbWithoutCleanup.MaterializedViews[file_name];
139+
Assert.NotNull(mv_with_cleanup);
140+
Assert.NotNull(mv_without_cleanup);
141+
Assert.Equal(should_match, mv_without_cleanup.Query == mv_with_cleanup.Query);
142+
143+
Assert.DoesNotContain("Preformatted", mv_with_cleanup.Query);
144+
Assert.DoesNotContain("Preformatted", mv_without_cleanup.Query);
145+
}
146+
AssertMaterializedView("mv", false);
147+
AssertMaterializedView("mv_preformatted", true);
148+
}
149+
150+
[Fact]
151+
public async Task VerifyFunctionWithCommentAtEnd()
152+
{
153+
// This test verifies that functions with comments at the end without a newline
154+
// are handled correctly when scripts are generated
155+
156+
// Arrange - First load the database
157+
var factory = new YamlDatabaseHandlerFactory<Model.Database>()
158+
.WithPlugin(new TablePlugin())
159+
.WithPlugin(new FunctionPlugin())
160+
.WithPlugin(new DatabaseCleanup());
161+
var loader = factory.Create(Path.Combine(BasePath, Deployment), Database);
162+
163+
// Act - Load the database
164+
var db = await loader.LoadAsync();
165+
var commentEndFunction = db.Functions["COMMENT_END"];
166+
Assert.NotNull(commentEndFunction);
167+
168+
// Generate the script container for the function
169+
var scriptContainers = commentEndFunction.CreateScripts("COMMENT_END", false);
170+
Assert.Single(scriptContainers);
171+
172+
var script = scriptContainers[0].Script.Text;
173+
var expected = ".create-or-alter function with(SkipValidation=```False```, View=```False```, Folder=```test```, DocString=```Function with comment at end```) COMMENT_END () { sourceTable\n| limit 100\n| where IsNotEmpty(EventId) // this is a comment at the end\n }";
174+
Assert.Equal(expected, script);
41175
}
42176
}
43-
}
177+
}

KustoSchemaTools/Model/Function.cs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using KustoSchemaTools.Parser;
55
using System.Text;
66
using YamlDotNet.Serialization;
7+
using System.Xml.Schema;
78

89
namespace KustoSchemaTools.Model
910
{
@@ -15,43 +16,97 @@ public class Function : IKustoBaseEntity
1516
public string DocString { get; set; } = "";
1617
public string Parameters { get; set; } = "";
1718
[YamlMember(ScalarStyle = YamlDotNet.Core.ScalarStyle.Literal)]
18-
19+
public bool Preformatted { get; set; } = false;
1920
public string Body { get; set; }
2021

2122
public List<DatabaseScriptContainer> CreateScripts(string name, bool isNew)
2223
{
24+
// load the non-query parts of the yaml model
25+
var excludedProperties = new HashSet<string>(["Body", "Parameters", "Preformatted"]);
2326
var properties = GetType().GetProperties()
24-
.Where(p => p.GetValue(this) != null && p.Name != "Body" && p.Name != "Parameters")
27+
.Where(p => p.GetValue(this) != null && !excludedProperties.Contains(p.Name))
2528
.Select(p => $"{p.Name}=```{p.GetValue(this)}```");
2629
var propertiesString = string.Join(", ", properties);
2730

31+
// Process function parameters to ensure proper syntax when creating Kusto function
2832
var parameters = Parameters;
2933
if (!string.IsNullOrWhiteSpace(Parameters))
3034
{
35+
// PARAMETER PROCESSING WORKFLOW:
36+
// 1. Create a dummy Kusto function that uses our parameters to leverage Kusto parser
37+
// 2. Parse the function to extract parameter declarations AST
38+
// 3. For each parameter name, apply bracketing if needed (for identifiers with special chars)
39+
// 4. Reconstruct the parameter string with properly formatted parameter names
40+
41+
// Create a simple dummy function to parse, embedding our parameters
3142
var dummyFunction = $"let x = ({parameters}) {{print \"abc\"}}";
3243
var parsed = KustoCode.Parse(dummyFunction);
3344

45+
// Extract all parameter name declarations from the parsed syntax tree
3446
var descs = parsed.Syntax
3547
.GetDescendants<FunctionParameters>()
3648
.First()
3749
.GetDescendants<NameDeclaration>()
3850
.ToList();
3951

52+
// Rebuild the parameters string with proper bracketing for each parameter name
4053
var sb = new StringBuilder();
4154
int lastPos = 0;
4255
foreach (var desc in descs)
4356
{
57+
// Apply bracketing to parameter name if needed (for identifiers with spaces or special chars)
4458
var bracketified = desc.Name.ToString().Trim().BracketIfIdentifier();
59+
60+
// Append everything from the last position up to the current parameter name
4561
sb.Append(dummyFunction[lastPos..desc.TextStart]);
62+
63+
// Append the properly bracketed parameter name
4664
sb.Append(bracketified);
65+
66+
// Update position tracker to end of this parameter name
4767
lastPos = desc.End;
4868
}
69+
70+
// Append any remaining text after the last parameter
4971
sb.Append(dummyFunction.Substring(lastPos));
5072
var replacedFunction = sb.ToString();
73+
74+
// Extract just the parameter portion from the reconstructed dummy function
75+
// The slice removes "let x = (" from the start and "){print "abc"}" from the end
5176
parameters = replacedFunction[9..^15];
5277
}
5378

54-
return new List<DatabaseScriptContainer> { new DatabaseScriptContainer("CreateOrAlterFunction", 40, $".create-or-alter function with({propertiesString}) {name} ({parameters}) {{ {Body} }}") };
79+
// Normalize the body to ensure it ends with exactly one newline character
80+
// and remove trailing whitespace from each line
81+
string normalizedBody = Body;
82+
83+
if (string.IsNullOrEmpty(normalizedBody))
84+
{
85+
// Empty body case
86+
normalizedBody = string.Empty;
87+
}
88+
else
89+
{
90+
// Split the body into lines, trim each line, and rejoin
91+
string[] lines = normalizedBody.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
92+
93+
// Process all lines except the last one
94+
for (int i = 0; i < lines.Length - 1; i++)
95+
{
96+
lines[i] = lines[i].TrimEnd();
97+
}
98+
99+
// Handle the last line separately - no need to trim trailing newlines since we split on them
100+
if (lines.Length > 0)
101+
{
102+
lines[lines.Length - 1] = lines[lines.Length - 1].TrimEnd();
103+
}
104+
105+
// Rejoin the lines and add exactly one newline character at the end
106+
normalizedBody = string.Join(Environment.NewLine, lines) + Environment.NewLine;
107+
}
108+
109+
return new List<DatabaseScriptContainer> { new DatabaseScriptContainer("CreateOrAlterFunction", 40, $".create-or-alter function with({propertiesString}) {name} ({parameters}) {{ {normalizedBody} }}") };
55110
}
56111
}
57112

0 commit comments

Comments
 (0)