Skip to content

Commit 38ad5ff

Browse files
authored
Merge pull request #169 from saschagrunert/fix-issue-116-pointer-to-slice-with-minzero
Allow pointer-to-slice/map with explicit MinItems=0/MinProperties=0
2 parents 82d79d9 + d169c37 commit 38ad5ff

File tree

9 files changed

+266
-31
lines changed

9 files changed

+266
-31
lines changed

pkg/analysis/utils/serialization/serialization_check.go

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/extractjsontags"
2424
markershelper "sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/markers"
2525
"sigs.k8s.io/kube-api-linter/pkg/analysis/utils"
26+
"sigs.k8s.io/kube-api-linter/pkg/markers"
2627
)
2728

2829
// SerializationCheck is an interface for checking serialization of fields.
@@ -98,31 +99,31 @@ func (s *serializationCheck) Check(pass *analysis.Pass, field *ast.Field, marker
9899
switch s.pointerPreference {
99100
case PointersPreferenceAlways:
100101
// The field must always be a pointer, pointers require omitempty, so enforce that too.
101-
s.handleFieldShouldBePointer(pass, field, fieldName, isPointer, underlying, "should be a pointer.")
102+
s.handleFieldShouldBePointer(pass, field, fieldName, isPointer, underlying, markersAccess, "should be a pointer.")
102103
s.handleFieldShouldHaveOmitEmpty(pass, field, fieldName, hasOmitEmpty, jsonTags)
103104
case PointersPreferenceWhenRequired:
104-
s.handleFieldOmitZero(pass, field, fieldName, jsonTags, hasOmitZero, hasValidZeroValue, isPointer, isStruct)
105+
s.handleFieldOmitZero(pass, field, fieldName, jsonTags, underlying, hasOmitZero, hasValidZeroValue, isPointer, isStruct, markersAccess)
105106

106107
if s.omitEmptyPolicy != OmitEmptyPolicyIgnore || hasOmitEmpty {
107108
// If we require omitempty, or the field has omitempty, we can check the field properties based on it being an omitempty field.
108-
s.checkFieldPropertiesWithOmitEmptyRequired(pass, field, fieldName, jsonTags, underlying, hasOmitEmpty, hasValidZeroValue, completeValidation, isPointer, isStruct)
109+
s.checkFieldPropertiesWithOmitEmptyRequired(pass, field, fieldName, jsonTags, underlying, hasOmitEmpty, hasValidZeroValue, completeValidation, isPointer, isStruct, markersAccess)
109110
} else {
110111
// The field does not have omitempty, and does not require it.
111-
s.checkFieldPropertiesWithoutOmitEmpty(pass, field, fieldName, jsonTags, underlying, hasValidZeroValue, completeValidation, isPointer, isStruct)
112+
s.checkFieldPropertiesWithoutOmitEmpty(pass, field, fieldName, jsonTags, underlying, hasValidZeroValue, completeValidation, isPointer, isStruct, markersAccess)
112113
}
113114
default:
114115
panic(fmt.Sprintf("unknown pointer preference: %s", s.pointerPreference))
115116
}
116117
}
117118

118-
func (s *serializationCheck) handleFieldOmitZero(pass *analysis.Pass, field *ast.Field, fieldName string, jsonTags extractjsontags.FieldTagInfo, hasOmitZero, hasValidZeroValue, isPointer, isStruct bool) {
119+
func (s *serializationCheck) handleFieldOmitZero(pass *analysis.Pass, field *ast.Field, fieldName string, jsonTags extractjsontags.FieldTagInfo, underlying ast.Expr, hasOmitZero, hasValidZeroValue, isPointer, isStruct bool, markersAccess markershelper.Markers) {
119120
switch s.omitZeroPolicy {
120121
case OmitZeroPolicyForbid:
121122
// when the omitzero policy is set to forbid, we need to report removing omitzero if set on the struct fields.
122123
s.checkFieldPropertiesWithOmitZeroForbidPolicy(pass, field, fieldName, isStruct, hasOmitZero, jsonTags)
123124
case OmitZeroPolicyWarn, OmitZeroPolicySuggestFix:
124125
// If we require omitzero, or the field has omitzero, we can check the field properties based on it being an omitzero field.
125-
s.checkFieldPropertiesWithOmitZeroRequired(pass, field, fieldName, jsonTags, hasOmitZero, isPointer, isStruct, hasValidZeroValue)
126+
s.checkFieldPropertiesWithOmitZeroRequired(pass, field, fieldName, jsonTags, underlying, hasOmitZero, isPointer, isStruct, hasValidZeroValue, markersAccess)
126127
default:
127128
panic(fmt.Sprintf("unknown omit zero policy: %s", s.omitZeroPolicy))
128129
}
@@ -136,7 +137,7 @@ func (s *serializationCheck) handleFieldShouldHaveOmitEmpty(pass *analysis.Pass,
136137
reportShouldAddOmitEmpty(pass, field, s.omitEmptyPolicy, fieldName, "field %s should have the omitempty tag.", jsonTags)
137138
}
138139

139-
func (s *serializationCheck) checkFieldPropertiesWithOmitEmptyRequired(pass *analysis.Pass, field *ast.Field, fieldName string, jsonTags extractjsontags.FieldTagInfo, underlying ast.Expr, hasOmitEmpty, hasValidZeroValue, completeValidation, isPointer, isStruct bool) {
140+
func (s *serializationCheck) checkFieldPropertiesWithOmitEmptyRequired(pass *analysis.Pass, field *ast.Field, fieldName string, jsonTags extractjsontags.FieldTagInfo, underlying ast.Expr, hasOmitEmpty, hasValidZeroValue, completeValidation, isPointer, isStruct bool, markersAccess markershelper.Markers) {
140141
switch {
141142
case isStruct && !hasValidZeroValue && s.omitZeroPolicy != OmitZeroPolicyForbid:
142143
// The struct field need not be pointer if it does not have a valid zero value.
@@ -145,27 +146,27 @@ func (s *serializationCheck) checkFieldPropertiesWithOmitEmptyRequired(pass *ana
145146
zeroValue := utils.GetTypedZeroValue(pass, underlying)
146147
validationHint := utils.GetTypedValidationHint(pass, underlying)
147148

148-
s.handleFieldShouldBePointer(pass, field, fieldName, isPointer, underlying, fmt.Sprintf("has a valid zero value (%s), but the validation is not complete (e.g. %s). The field should be a pointer to allow the zero value to be set. If the zero value is not a valid use case, complete the validation and remove the pointer.", zeroValue, validationHint))
149+
s.handleFieldShouldBePointer(pass, field, fieldName, isPointer, underlying, markersAccess, fmt.Sprintf("has a valid zero value (%s), but the validation is not complete (e.g. %s). The field should be a pointer to allow the zero value to be set. If the zero value is not a valid use case, complete the validation and remove the pointer.", zeroValue, validationHint))
149150
case hasValidZeroValue, isStruct:
150151
// The field validation infers that the zero value is valid, the field needs to be a pointer.
151152
// Structs with omitempty should always be pointers, else they won't actually be omitted.
152153
zeroValue := utils.GetTypedZeroValue(pass, underlying)
153154

154-
s.handleFieldShouldBePointer(pass, field, fieldName, isPointer, underlying, fmt.Sprintf("has a valid zero value (%s) and should be a pointer.", zeroValue))
155+
s.handleFieldShouldBePointer(pass, field, fieldName, isPointer, underlying, markersAccess, fmt.Sprintf("has a valid zero value (%s) and should be a pointer.", zeroValue))
155156
case !hasValidZeroValue && completeValidation && !isStruct:
156157
// The validation is fully complete, and the zero value is not valid, so we don't need a pointer.
157-
s.handleFieldShouldNotBePointer(pass, field, fieldName, isPointer, "field %s does not allow the zero value. The field does not need to be a pointer.")
158+
s.handleFieldShouldNotBePointer(pass, field, fieldName, isPointer, underlying, markersAccess, "field %s does not allow the zero value. The field does not need to be a pointer.")
158159
}
159160

160161
// In this case, we should always add the omitempty if it isn't present.
161162
s.handleFieldShouldHaveOmitEmpty(pass, field, fieldName, hasOmitEmpty, jsonTags)
162163
}
163164

164-
func (s *serializationCheck) checkFieldPropertiesWithoutOmitEmpty(pass *analysis.Pass, field *ast.Field, fieldName string, jsonTags extractjsontags.FieldTagInfo, underlying ast.Expr, hasValidZeroValue, completeValidation, isPointer, isStruct bool) {
165+
func (s *serializationCheck) checkFieldPropertiesWithoutOmitEmpty(pass *analysis.Pass, field *ast.Field, fieldName string, jsonTags extractjsontags.FieldTagInfo, underlying ast.Expr, hasValidZeroValue, completeValidation, isPointer, isStruct bool, markersAccess markershelper.Markers) {
165166
switch {
166167
case hasValidZeroValue:
167168
// The field is not omitempty, and the zero value is valid, the field does not need to be a pointer.
168-
s.handleFieldShouldNotBePointer(pass, field, fieldName, isPointer, "field %s does not have omitempty and allows the zero value. The field does not need to be a pointer.")
169+
s.handleFieldShouldNotBePointer(pass, field, fieldName, isPointer, underlying, markersAccess, "field %s does not have omitempty and allows the zero value. The field does not need to be a pointer.")
169170
case !hasValidZeroValue:
170171
// The zero value would not be accepted, so the field needs to have omitempty.
171172
// Force the omitempty policy to suggest a fix. We can only get to this function when the policy is configured to Ignore.
@@ -174,17 +175,17 @@ func (s *serializationCheck) checkFieldPropertiesWithoutOmitEmpty(pass *analysis
174175
// Once it has the omitempty tag, it will also need to be a pointer in some cases.
175176
// Now handle it as if it had the omitempty already.
176177
// We already handle the omitempty tag above, so force the `hasOmitEmpty` to true.
177-
s.checkFieldPropertiesWithOmitEmptyRequired(pass, field, fieldName, jsonTags, underlying, true, hasValidZeroValue, completeValidation, isPointer, isStruct)
178+
s.checkFieldPropertiesWithOmitEmptyRequired(pass, field, fieldName, jsonTags, underlying, true, hasValidZeroValue, completeValidation, isPointer, isStruct, markersAccess)
178179
}
179180
}
180181

181-
func (s *serializationCheck) checkFieldPropertiesWithOmitZeroRequired(pass *analysis.Pass, field *ast.Field, fieldName string, jsonTags extractjsontags.FieldTagInfo, hasOmitZero, isPointer, isStruct, hasValidZeroValue bool) {
182+
func (s *serializationCheck) checkFieldPropertiesWithOmitZeroRequired(pass *analysis.Pass, field *ast.Field, fieldName string, jsonTags extractjsontags.FieldTagInfo, underlying ast.Expr, hasOmitZero, isPointer, isStruct, hasValidZeroValue bool, markersAccess markershelper.Markers) {
182183
if !isStruct || hasValidZeroValue {
183184
return
184185
}
185186

186187
s.handleFieldShouldHaveOmitZero(pass, field, fieldName, hasOmitZero, jsonTags)
187-
s.handleFieldShouldNotBePointer(pass, field, fieldName, isPointer, "field %s does not allow the zero value. The field does not need to be a pointer.")
188+
s.handleFieldShouldNotBePointer(pass, field, fieldName, isPointer, underlying, markersAccess, "field %s does not allow the zero value. The field does not need to be a pointer.")
188189
}
189190

190191
func (s *serializationCheck) checkFieldPropertiesWithOmitZeroForbidPolicy(pass *analysis.Pass, field *ast.Field, fieldName string, isStruct, hasOmitZero bool, jsonTags extractjsontags.FieldTagInfo) {
@@ -205,15 +206,12 @@ func (s *serializationCheck) handleFieldShouldHaveOmitZero(pass *analysis.Pass,
205206
reportShouldAddOmitZero(pass, field, s.omitZeroPolicy, fieldName, "field %s does not allow the zero value. It must have the omitzero tag.", jsonTags)
206207
}
207208

208-
func (s *serializationCheck) handleFieldShouldBePointer(pass *analysis.Pass, field *ast.Field, fieldName string, isPointer bool, underlying ast.Expr, reason string) {
209+
func (s *serializationCheck) handleFieldShouldBePointer(pass *analysis.Pass, field *ast.Field, fieldName string, isPointer bool, underlying ast.Expr, markersAccess markershelper.Markers, reason string) {
209210
if utils.IsPointerType(pass, underlying) {
210211
if isPointer {
211-
switch s.pointerPolicy {
212-
case PointersPolicySuggestFix:
213-
reportShouldRemovePointer(pass, field, PointersPolicySuggestFix, fieldName, "field %s underlying type does not need to be a pointer. The pointer should be removed.", fieldName)
214-
case PointersPolicyWarn:
215-
pass.Reportf(field.Pos(), "field %s underlying type does not need to be a pointer. The pointer should be removed.", fieldName)
216-
}
212+
s.handlePointerToPointerType(pass, field, fieldName, underlying, markersAccess)
213+
} else if s.pointerPreference == PointersPreferenceAlways {
214+
s.handleNonPointerToPointerType(pass, field, fieldName, underlying, markersAccess)
217215
}
218216

219217
return
@@ -223,6 +221,35 @@ func (s *serializationCheck) handleFieldShouldBePointer(pass *analysis.Pass, fie
223221
return
224222
}
225223

224+
s.reportShouldAddPointerMessage(pass, field, fieldName, reason)
225+
}
226+
227+
func (s *serializationCheck) handlePointerToPointerType(pass *analysis.Pass, field *ast.Field, fieldName string, underlying ast.Expr, markersAccess markershelper.Markers) {
228+
// Check if this is a pointer-to-slice/map with explicit MinItems=0 or MinProperties=0
229+
// In this case, the pointer is intentional to distinguish nil from empty
230+
if hasExplicitZeroMinValidation(pass, field, underlying, markersAccess) {
231+
return
232+
}
233+
234+
switch s.pointerPolicy {
235+
case PointersPolicySuggestFix:
236+
reportShouldRemovePointer(pass, field, PointersPolicySuggestFix, fieldName, "field %s underlying type does not need to be a pointer. The pointer should be removed.", fieldName)
237+
case PointersPolicyWarn:
238+
pass.Reportf(field.Pos(), "field %s underlying type does not need to be a pointer. The pointer should be removed.", fieldName)
239+
}
240+
}
241+
242+
func (s *serializationCheck) handleNonPointerToPointerType(pass *analysis.Pass, field *ast.Field, fieldName string, underlying ast.Expr, markersAccess markershelper.Markers) {
243+
// Check if this is a slice/map WITHOUT a pointer but with explicit MinItems=0 or MinProperties=0
244+
// In this case, we should suggest adding a pointer to distinguish nil from empty
245+
if !hasExplicitZeroMinValidation(pass, field, underlying, markersAccess) {
246+
return
247+
}
248+
249+
s.reportShouldAddPointerMessage(pass, field, fieldName, "with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil (unset) from empty.")
250+
}
251+
252+
func (s *serializationCheck) reportShouldAddPointerMessage(pass *analysis.Pass, field *ast.Field, fieldName, reason string) {
226253
switch s.pointerPolicy {
227254
case PointersPolicySuggestFix:
228255
reportShouldAddPointer(pass, field, PointersPolicySuggestFix, fieldName, "field %s %s", fieldName, reason)
@@ -231,10 +258,38 @@ func (s *serializationCheck) handleFieldShouldBePointer(pass *analysis.Pass, fie
231258
}
232259
}
233260

234-
func (s *serializationCheck) handleFieldShouldNotBePointer(pass *analysis.Pass, field *ast.Field, fieldName string, isPointer bool, message string) {
261+
func (s *serializationCheck) handleFieldShouldNotBePointer(pass *analysis.Pass, field *ast.Field, fieldName string, isPointer bool, underlying ast.Expr, markersAccess markershelper.Markers, message string) {
235262
if !isPointer {
236263
return
237264
}
238265

266+
// Check if this is a pointer-to-slice/map with explicit MinItems=0 or MinProperties=0
267+
// In this case, the pointer is intentional to distinguish nil from empty
268+
if hasExplicitZeroMinValidation(pass, field, underlying, markersAccess) {
269+
return
270+
}
271+
239272
reportShouldRemovePointer(pass, field, s.pointerPolicy, fieldName, message, fieldName)
240273
}
274+
275+
// hasExplicitZeroMinValidation checks if a field has an explicit MinItems=0 or MinProperties=0 marker.
276+
// This indicates the developer intentionally wants to distinguish between nil and empty for slices/maps:
277+
// - nil: field not provided by the user, use defaults or treat as unset
278+
// - []/{}: explicitly set to empty by the user
279+
//
280+
// Using a pointer allows preserving this semantic difference, which is why MinItems=0/MinProperties=0
281+
// combined with a pointer is a valid pattern despite slices/maps being reference types.
282+
func hasExplicitZeroMinValidation(pass *analysis.Pass, field *ast.Field, underlying ast.Expr, markersAccess markershelper.Markers) bool {
283+
fieldMarkers := utils.TypeAwareMarkerCollectionForField(pass, markersAccess, field)
284+
285+
switch underlying.(type) {
286+
case *ast.ArrayType:
287+
// Check for explicit MinItems=0
288+
return fieldMarkers.HasWithValue(markers.KubebuilderMinItemsMarker + "=0")
289+
case *ast.MapType:
290+
// Check for explicit MinProperties=0
291+
return fieldMarkers.HasWithValue(markers.KubebuilderMinPropertiesMarker + "=0")
292+
}
293+
294+
return false
295+
}

pkg/analysis/utils/serialization/testdata/src/pointers_always/arrays.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ type TestArrays struct {
1616
ArrayWithPositiveMinItemsWithOmitEmpty []string `json:"arrayWithPositiveMinItemsWithOmitEmpty,omitempty"`
1717

1818
// +kubebuilder:validation:MinItems=0
19-
ArrayWithZeroMinItems []string `json:"arrayWithZeroMinItems"` // want "field ArrayWithZeroMinItems should have the omitempty tag."
19+
ArrayWithZeroMinItems []string `json:"arrayWithZeroMinItems"` // want "field ArrayWithZeroMinItems should have the omitempty tag." "field ArrayWithZeroMinItems with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil \\(unset\\) from empty."
2020

2121
// +kubebuilder:validation:MinItems=0
22-
ArrayWithZeroMinItemsWithOmitEmpty []string `json:"arrayWithZeroMinItemsWithOmitEmpty,omitempty"`
22+
ArrayWithZeroMinItemsWithOmitEmpty []string `json:"arrayWithZeroMinItemsWithOmitEmpty,omitempty"` // want "field ArrayWithZeroMinItemsWithOmitEmpty with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil \\(unset\\) from empty."
2323

2424
ByteArray []byte `json:"byteArray"` // want "field ByteArray should have the omitempty tag."
2525

pkg/analysis/utils/serialization/testdata/src/pointers_always/arrays.go.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ type TestArrays struct {
1616
ArrayWithPositiveMinItemsWithOmitEmpty []string `json:"arrayWithPositiveMinItemsWithOmitEmpty,omitempty"`
1717

1818
// +kubebuilder:validation:MinItems=0
19-
ArrayWithZeroMinItems []string `json:"arrayWithZeroMinItems,omitempty"` // want "field ArrayWithZeroMinItems should have the omitempty tag."
19+
ArrayWithZeroMinItems *[]string `json:"arrayWithZeroMinItems,omitempty"` // want "field ArrayWithZeroMinItems should have the omitempty tag." "field ArrayWithZeroMinItems with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil \\(unset\\) from empty."
2020

2121
// +kubebuilder:validation:MinItems=0
22-
ArrayWithZeroMinItemsWithOmitEmpty []string `json:"arrayWithZeroMinItemsWithOmitEmpty,omitempty"`
22+
ArrayWithZeroMinItemsWithOmitEmpty *[]string `json:"arrayWithZeroMinItemsWithOmitEmpty,omitempty"` // want "field ArrayWithZeroMinItemsWithOmitEmpty with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil \\(unset\\) from empty."
2323

2424
ByteArray []byte `json:"byteArray,omitempty"` // want "field ByteArray should have the omitempty tag."
2525

pkg/analysis/utils/serialization/testdata/src/pointers_always/maps.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ type TestMaps struct {
1616
MapWithPositiveMinPropertiesWithOmitEmpty map[string]string `json:"mapWithPositiveMinPropertiesWithOmitEmpty,omitempty"`
1717

1818
// +kubebuilder:validation:MinProperties=0
19-
MapWithZeroMinProperties map[string]string `json:"mapWithZeroMinProperties"` // want "field MapWithZeroMinProperties should have the omitempty tag."
19+
MapWithZeroMinProperties map[string]string `json:"mapWithZeroMinProperties"` // want "field MapWithZeroMinProperties should have the omitempty tag." "field MapWithZeroMinProperties with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil \\(unset\\) from empty."
2020

2121
// +kubebuilder:validation:MinProperties=0
22-
MapWithZeroMinPropertiesWithOmitEmpty map[string]string `json:"mapWithZeroMinPropertiesWithOmitEmpty,omitempty"`
22+
MapWithZeroMinPropertiesWithOmitEmpty map[string]string `json:"mapWithZeroMinPropertiesWithOmitEmpty,omitempty"` // want "field MapWithZeroMinPropertiesWithOmitEmpty with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil \\(unset\\) from empty."
2323
}

pkg/analysis/utils/serialization/testdata/src/pointers_always/maps.go.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ type TestMaps struct {
1616
MapWithPositiveMinPropertiesWithOmitEmpty map[string]string `json:"mapWithPositiveMinPropertiesWithOmitEmpty,omitempty"`
1717

1818
// +kubebuilder:validation:MinProperties=0
19-
MapWithZeroMinProperties map[string]string `json:"mapWithZeroMinProperties,omitempty"` // want "field MapWithZeroMinProperties should have the omitempty tag."
19+
MapWithZeroMinProperties *map[string]string `json:"mapWithZeroMinProperties,omitempty"` // want "field MapWithZeroMinProperties should have the omitempty tag." "field MapWithZeroMinProperties with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil \\(unset\\) from empty."
2020

2121
// +kubebuilder:validation:MinProperties=0
22-
MapWithZeroMinPropertiesWithOmitEmpty map[string]string `json:"mapWithZeroMinPropertiesWithOmitEmpty,omitempty"`
22+
MapWithZeroMinPropertiesWithOmitEmpty *map[string]string `json:"mapWithZeroMinPropertiesWithOmitEmpty,omitempty"` // want "field MapWithZeroMinPropertiesWithOmitEmpty with MinItems=0/MinProperties=0, underlying type should be a pointer to distinguish nil \\(unset\\) from empty."
2323
}

0 commit comments

Comments
 (0)