Skip to content

Commit add43d3

Browse files
feat: enhance nullable value handling in resource list columns (#1294)
1 parent 2addd3e commit add43d3

File tree

3 files changed

+145
-57
lines changed

3 files changed

+145
-57
lines changed

src/KubeUI.Avalonia/Resources/CRDResourceConfig.cs

Lines changed: 36 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,15 @@ public CRDResourceConfig(IServiceProvider serviceProvider)
2929
public void Generate(V1CustomResourceDefinition crd)
3030
{
3131
_generatedName = crd.Spec?.Names?.Kind.Humanize(LetterCasing.Title).Pluralize() ?? base.Name;
32+
var spec = crd.Spec ?? throw new InvalidOperationException("CRD spec is missing.");
3233

3334
// Add Name Column
3435
_columns.Add(NameColumn(SortDirection.Ascending));
3536

36-
var version = crd.Spec.Versions.First(x => x.Storage);
37+
var version = spec.Versions.First(x => x.Storage);
3738

3839
//Check if its a namespaced crd
39-
if (crd.Spec.Scope == "Namespaced")
40+
if (spec.Scope == "Namespaced")
4041
{
4142
// Add Namespace Column
4243
_columns.Add(NamespaceColumn());
@@ -63,94 +64,55 @@ public void Generate(V1CustomResourceDefinition crd)
6364
{
6465
var exp = JsonPathLINQ.JsonPath.GetExpression<T, string>(item.JsonPath, true);
6566

66-
var colDef = new ResourceListColumn<T, string>()
67-
{
68-
Name = item.Name,
69-
Field = exp.Compile(),
70-
//Width = "*"
71-
};
67+
var colDef = CreateColumn(item.Name, exp);
7268

7369
_columns.Add(colDef);
7470
}
7571
else if (item.Type == "number")
7672
{
77-
var exp = JsonPathLINQ.JsonPath.GetExpression<T, double>(item.JsonPath, true);
73+
var exp = JsonPathLINQ.JsonPath.GetExpression<T, double?>(item.JsonPath, true);
7874

79-
var colDef = new ResourceListColumn<T, double>()
80-
{
81-
Name = item.Name,
82-
Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
83-
Field = exp.Compile(),
84-
//Width = "*"
85-
};
75+
var colDef = CreateColumn(item.Name, exp, TransformToFuncOfString(exp.Body, exp.Parameters).Compile());
8676

8777
_columns.Add(colDef);
8878
}
8979
else if (item.Type == "integer" && item.Format == "int64")
9080
{
91-
var exp = JsonPathLINQ.JsonPath.GetExpression<T, long>(item.JsonPath, true);
81+
var exp = JsonPathLINQ.JsonPath.GetExpression<T, long?>(item.JsonPath, true);
9282

93-
var colDef = new ResourceListColumn<T, long>()
94-
{
95-
Name = item.Name,
96-
Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
97-
Field = exp.Compile(),
98-
//Width = "*"
99-
};
83+
var colDef = CreateColumn(item.Name, exp, TransformToFuncOfString(exp.Body, exp.Parameters).Compile());
10084

10185
_columns.Add(colDef);
10286
}
10387
else if (item.Type == "integer")
10488
{
105-
var exp = JsonPathLINQ.JsonPath.GetExpression<T, int>(item.JsonPath, true);
89+
var exp = JsonPathLINQ.JsonPath.GetExpression<T, int?>(item.JsonPath, true);
10690

107-
var colDef = new ResourceListColumn<T, int>()
108-
{
109-
Name = item.Name,
110-
Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
111-
Field = exp.Compile(),
112-
//Width = "*"
113-
};
91+
var colDef = CreateColumn(item.Name, exp, TransformToFuncOfString(exp.Body, exp.Parameters).Compile());
11492

11593
_columns.Add(colDef);
11694
}
11795
else if (item.Type == "date")
11896
{
119-
var exp = JsonPathLINQ.JsonPath.GetExpression<T, DateTime>(item.JsonPath, true);
97+
var exp = JsonPathLINQ.JsonPath.GetExpression<T, DateTime?>(item.JsonPath, true);
12098

121-
var colDef = new ResourceListColumn<T, DateTime>()
122-
{
123-
Name = item.Name,
124-
Field = exp.Compile(),
125-
//Width = "*"
126-
};
99+
var colDef = CreateColumn(item.Name, exp);
127100

128101
_columns.Add(colDef);
129102
}
130103
else if (item.Type == "boolean")
131104
{
132-
var exp = JsonPathLINQ.JsonPath.GetExpression<T, bool>(item.JsonPath, true);
105+
var exp = JsonPathLINQ.JsonPath.GetExpression<T, bool?>(item.JsonPath, true);
133106

134-
var colDef = new ResourceListColumn<T, bool>()
135-
{
136-
Name = item.Name,
137-
Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
138-
Field = exp.Compile(),
139-
//Width = "*"
140-
};
107+
var colDef = CreateColumn(item.Name, exp, TransformToFuncOfString(exp.Body, exp.Parameters).Compile());
141108

142109
_columns.Add(colDef);
143110
}
144111
else if (item.Type == "enum")
145112
{
146113
var exp = JsonPathLINQ.JsonPath.GetExpression<T, Enum>(item.JsonPath, true);
147114

148-
var colDef = new ResourceListColumn<T, string>()
149-
{
150-
Name = item.Name,
151-
Field = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
152-
//Width = "*"
153-
};
115+
var colDef = CreateColumn<string>(item.Name, TransformToFuncOfString(exp.Body, exp.Parameters).Compile());
154116

155117
_columns.Add(colDef);
156118
}
@@ -253,7 +215,7 @@ private static Expression<Func<T, string>> TransformToFuncOfString(Expression ex
253215
{
254216
// Create a method to get the enum member name from the JsonStringEnumMemberNameAttribute
255217
var getEnumMemberNameMethod = typeof(CRDResourceConfig<>).GetMethod(nameof(GetEnumMemberName), BindingFlags.NonPublic | BindingFlags.Static)
256-
.MakeGenericMethod(expression.Type);
218+
?.MakeGenericMethod(expression.Type) ?? throw new InvalidOperationException("Unable to resolve enum member formatter.");
257219

258220
// Call the method to get the enum member name
259221
var bodyAsString = Expression.Call(getEnumMemberNameMethod, expression);
@@ -284,6 +246,26 @@ private static Expression<Func<T, string>> TransformToFuncOfString(Expression ex
284246
}
285247
}
286248

249+
private static ResourceListColumn<T, TValue> CreateColumn<TValue>(string name, Expression<Func<T, TValue>> expression, Func<T, string>? display = null)
250+
{
251+
return new ResourceListColumn<T, TValue>()
252+
{
253+
Name = name,
254+
Display = display,
255+
Field = expression.Compile(),
256+
};
257+
}
258+
259+
private static ResourceListColumn<T, TValue> CreateColumn<TValue>(string name, Func<T, TValue> field, Func<T, string>? display = null)
260+
{
261+
return new ResourceListColumn<T, TValue>()
262+
{
263+
Name = name,
264+
Display = display,
265+
Field = field,
266+
};
267+
}
268+
287269
private static string GetEnumMemberName<TEnum>(TEnum enumValue) where TEnum : Enum
288270
{
289271
var memberInfo = typeof(TEnum).GetMember(enumValue.ToString()).FirstOrDefault();

src/KubeUI.Avalonia/Resources/ResourceConfigBase.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ private bool CanRestart(IList? items)
426426

427427
public class ResourceListColumn<T, TValue> : IResourceListColumn where T : class, IKubernetesObject<V1ObjectMeta>, new()
428428
{
429+
private const string NullableValueMissingMessage = "Nullable object must have a value.";
429430
private Func<T, TValue>? _fieldAccessor;
430431
private IDataGridColumnValueAccessor? _valueAccessor;
431432
private string? _key;
@@ -464,15 +465,15 @@ public string Key
464465
public IDataGridColumnValueAccessor ValueAccessor => _valueAccessor ??= new LambdaColumnValueAccessor(GetFieldAccessor());
465466

466467
public Func<object, IComparable?> SortKey =>
467-
o => (IComparable?)(object?)GetFieldAccessor()((T)o);
468+
o => GetFieldValue((T)o) as IComparable;
468469

469470
public Func<object, string> DisplayValue =>
470471
o =>
471472
{
472473
var t = (T)o;
473474
if (Display != null)
474475
return Display(t);
475-
var v = GetFieldAccessor()(t);
476+
var v = GetFieldValue(t);
476477
return v?.ToString() ?? "";
477478
};
478479

@@ -482,6 +483,18 @@ private Func<T, TValue> GetFieldAccessor()
482483
return _fieldAccessor;
483484
}
484485

486+
private object? GetFieldValue(T item)
487+
{
488+
try
489+
{
490+
return GetFieldAccessor()(item);
491+
}
492+
catch (InvalidOperationException ex) when (ex.Message == NullableValueMissingMessage)
493+
{
494+
return null;
495+
}
496+
}
497+
485498
private static string NormalizeKey(string value)
486499
{
487500
if (string.IsNullOrWhiteSpace(value))
@@ -529,7 +542,14 @@ public LambdaColumnValueAccessor(Func<T, TValue> getter)
529542

530543
public object GetValue(object item)
531544
{
532-
return _getter((T)item)!;
545+
try
546+
{
547+
return _getter((T)item)!;
548+
}
549+
catch (InvalidOperationException ex) when (ex.Message == NullableValueMissingMessage)
550+
{
551+
return null!;
552+
}
533553
}
534554

535555
public void SetValue(object item, object value)

tests/KubeUI.Avalonia.Tests/Resources/V1CustomResourceDefinitionConfigTests.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,70 @@ public void generate_uses_humanized_plural_kind_for_display_name()
6262

6363
config.Name.ShouldBe("Ingress Classes");
6464
}
65+
66+
[AvaloniaFact]
67+
public void resource_list_column_value_accessor_returns_null_for_missing_nullable_values()
68+
{
69+
var column = new ResourceListColumn<NullableValueResource, int>
70+
{
71+
Name = "Value",
72+
Field = resource => resource.Value!.Value,
73+
};
74+
75+
var accessor = column.ValueAccessor;
76+
77+
Should.NotThrow(() => accessor.GetValue(new NullableValueResource()));
78+
accessor.GetValue(new NullableValueResource()).ShouldBeNull();
79+
column.DisplayValue(new NullableValueResource()).ShouldBeEmpty();
80+
}
81+
82+
[AvaloniaFact]
83+
public void crd_generator_uses_nullable_value_types_for_optional_printer_columns()
84+
{
85+
var cluster = new TestCluster().CreateWorkspace();
86+
var services = TestApp.CurrentServices ?? throw new InvalidOperationException("Test services are not initialized.");
87+
var config = ActivatorUtilities.CreateInstance<CRDResourceConfig<TestCustomResourceWithSpec>>(services);
88+
config.Initialize(cluster);
89+
90+
var crd = new V1CustomResourceDefinition
91+
{
92+
Spec = new V1CustomResourceDefinitionSpec
93+
{
94+
Group = "example.com",
95+
Scope = "Namespaced",
96+
Names = new V1CustomResourceDefinitionNames
97+
{
98+
Kind = "Example",
99+
Plural = "examples",
100+
},
101+
Versions =
102+
[
103+
new V1CustomResourceDefinitionVersion
104+
{
105+
Name = "v1",
106+
Storage = true,
107+
Served = true,
108+
AdditionalPrinterColumns =
109+
[
110+
new V1CustomResourceColumnDefinition
111+
{
112+
Name = "Revision",
113+
JsonPath = ".spec.revision",
114+
Type = "integer",
115+
}
116+
]
117+
}
118+
]
119+
}
120+
};
121+
122+
config.Generate(crd);
123+
124+
var column = config.Columns().Single(x => x.Name == "Revision");
125+
column.ValueType.ShouldBe(typeof(int?));
126+
column.ValueAccessor.GetValue(new TestCustomResourceWithSpec()).ShouldBeNull();
127+
column.DisplayValue(new TestCustomResourceWithSpec()).ShouldBeEmpty();
128+
}
65129
}
66130

67131
[KubernetesEntity(Group = "example.com", ApiVersion = "v1", Kind = "IngressClass")]
@@ -71,3 +135,25 @@ internal sealed class TestCustomResource : k8s.IKubernetesObject<V1ObjectMeta>
71135
public string Kind { get; set; } = "IngressClass";
72136
public V1ObjectMeta Metadata { get; set; } = new();
73137
}
138+
139+
internal sealed class NullableValueResource : k8s.IKubernetesObject<V1ObjectMeta>
140+
{
141+
public string ApiVersion { get; set; } = "v1";
142+
public string Kind { get; set; } = "Test";
143+
public V1ObjectMeta Metadata { get; set; } = new();
144+
public int? Value { get; set; }
145+
}
146+
147+
[KubernetesEntity(Group = "example.com", ApiVersion = "v1", Kind = "Example")]
148+
internal sealed class TestCustomResourceWithSpec : k8s.IKubernetesObject<V1ObjectMeta>
149+
{
150+
public string ApiVersion { get; set; } = "example.com/v1";
151+
public string Kind { get; set; } = "Example";
152+
public V1ObjectMeta Metadata { get; set; } = new();
153+
public TestCustomResourceSpec Spec { get; set; } = new();
154+
}
155+
156+
internal sealed class TestCustomResourceSpec
157+
{
158+
public int? Revision { get; set; }
159+
}

0 commit comments

Comments
 (0)