diff --git a/tools/scaffolder/internal/generate/indexers.go b/tools/scaffolder/internal/generate/indexers.go index e33f5f7a3f..62447f73bb 100644 --- a/tools/scaffolder/internal/generate/indexers.go +++ b/tools/scaffolder/internal/generate/indexers.go @@ -40,6 +40,9 @@ type ReferenceField struct { ReferencedGroup string ReferencedVersion string RequiredSegments []bool + IsArrayBased bool // true if reference is inside an array + ArrayPath string // path to the array container (e.g., "properties.spec.properties.entries") + ItemPath string // path within array item (e.g., "properties.secretRef") } type IndexerInfo struct { @@ -223,6 +226,9 @@ func processKubernetesMapping(v map[string]any, path string, requiredSegments [] reqCopy := make([]bool, len(requiredSegments)) copy(reqCopy, requiredSegments) + // Check if this reference is inside an array + arrayPath, itemPath, isArray := splitArrayPath(path) + ref := ReferenceField{ FieldName: fieldName, FieldPath: path, @@ -230,6 +236,9 @@ func processKubernetesMapping(v map[string]any, path string, requiredSegments [] ReferencedGroup: group, ReferencedVersion: version, RequiredSegments: reqCopy, + IsArrayBased: isArray, + ArrayPath: arrayPath, + ItemPath: itemPath, } *references = append(*references, ref) } @@ -328,14 +337,16 @@ func GenerateIndexers(resultPath, crdKind, indexerOutDir string) error { } // Group references by target kind (e.g., all Secret refs together, all Group refs together) - // Skip array-based references for now as they require iteration logic + // Skip nested array references (multiple .items.) as they require more complex iteration logic refsByKind := make(map[string][]ReferenceField) for _, ref := range references { - // Skip references that are arrays for now - if strings.Contains(ref.FieldPath, ".items.") { - fmt.Printf("Skipping array-based reference %s in %s (array indexing not yet supported)\n", ref.FieldName, crdKind) + // Count how many times .items. appears to detect nested arrays + itemsCount := strings.Count(ref.FieldPath, ".items.") + if itemsCount > 1 { + fmt.Printf("Skipping nested array reference %s in %s (nested arrays not supported)\n", ref.FieldName, crdKind) continue } + // Single-level arrays (itemsCount == 1) are now supported refsByKind[ref.ReferencedKind] = append(refsByKind[ref.ReferencedKind], ref) } @@ -464,36 +475,106 @@ func generateFieldExtractionCode(fields []ReferenceField) []jen.Code { code := make([]jen.Code, 0) for _, field := range fields { - // Build the field path from the FieldPath - // FieldPath looks like: "properties.spec.properties..properties.groupRef" - // We need to convert this to: resource.Spec..GroupRef - fieldAccessPath := buildFieldAccessPath(field.FieldPath) + // Check if this is an array-based reference + if field.IsArrayBased { + code = append(code, generateArrayFieldExtractionCode(field)) + } else { + // Original non-array logic + fieldAccessPath := buildFieldAccessPath(field.FieldPath) + + // Nil check if conditions + nilCheckCondition := buildNilCheckConditions(fieldAccessPath, field.RequiredSegments) + + // Add check that the Name field is not empty + condition := nilCheckCondition.Op("&&").Add( + jen.Id(fieldAccessPath).Dot("Name").Op("!=").Lit(""), + ) + + // Generate: if && resource.Spec..GroupRef.Name != "" { + // keys = append(keys, types.NamespacedName{...}.String()) + // } + code = append(code, + jen.If(condition).Block( + jen.Id("keys").Op("=").Append( + jen.Id("keys"), + jen.Qual("k8s.io/apimachinery/pkg/types", "NamespacedName").Values(jen.Dict{ + jen.Id("Name"): jen.Id(fieldAccessPath).Dot("Name"), + jen.Id("Namespace"): jen.Id("resource").Dot("Namespace"), + }).Dot("String").Call(), + ), + ), + ) + } + } - // Nil check if conditions - nilCheckCondition := buildNilCheckConditions(fieldAccessPath, field.RequiredSegments) + return code +} - // Add check that the Name field is not empty - condition := nilCheckCondition.Op("&&").Add( - jen.Id(fieldAccessPath).Dot("Name").Op("!=").Lit(""), - ) +// generateArrayFieldExtractionCode generates code for extracting keys from array-based references +func generateArrayFieldExtractionCode(field ReferenceField) jen.Code { + arrayAccessPath := buildFieldAccessPath(field.ArrayPath) + itemAccessPath := buildFieldAccessPath(field.ItemPath) + + arrayParts := strings.Split(field.ArrayPath, ".") + arrayFieldName := arrayParts[len(arrayParts)-1] + loopVar := generateLoopVariableName(arrayFieldName) - // Generate: if && resource.Spec..GroupRef.Name != "" { - // keys = append(keys, types.NamespacedName{...}.String()) - // } - code = append(code, - jen.If(condition).Block( + segmentsBeforeArray, segmentsInArray := splitRequiredSegments(field.FieldPath, field.RequiredSegments) + + // Build nil check for array container + arrayContainerCheck := buildNilCheckConditionsForArrayContainer(arrayAccessPath, segmentsBeforeArray) + + itemFieldPath := strings.Replace(itemAccessPath, "resource", loopVar, 1) + var itemNilCheck *jen.Statement + if len(segmentsInArray) > 0 { + // Build nil checks for the item field + itemNilCheck = buildNilCheckConditions(itemFieldPath, segmentsInArray) + } else { + // Fallback: check the field itself + itemNilCheck = jen.Id(itemFieldPath).Op("!=").Nil() + } + + // Add check for Name field + itemCondition := itemNilCheck.Op("&&").Add( + jen.Id(itemFieldPath).Dot("Name").Op("!=").Lit(""), + ) + + // Determine if the array field itself is a pointer (needs dereferencing in range) + // The array field is a pointer if it's not in the required list + arraySegments := strings.Split(arrayAccessPath, ".") + arrayFieldIsPointer := false + + if len(segmentsBeforeArray) > 0 && len(segmentsBeforeArray) == len(arraySegments)-1 { + lastSegmentRequired := segmentsBeforeArray[len(segmentsBeforeArray)-1] + arrayFieldIsPointer = !lastSegmentRequired + } else if len(segmentsBeforeArray) == 0 { + arrayFieldIsPointer = true + } + + var rangeTarget *jen.Statement + if arrayFieldIsPointer { + rangeTarget = jen.Op("*").Id(arrayAccessPath) + } else { + rangeTarget = jen.Id(arrayAccessPath) + } + + // Generate the complete if block with for loop + return jen.If(arrayContainerCheck).Block( + // Loop over array + jen.For( + jen.List(jen.Id("_"), jen.Id(loopVar)).Op(":=").Range().Add(rangeTarget), + ).Block( + jen.If(itemCondition).Block( jen.Id("keys").Op("=").Append( jen.Id("keys"), jen.Qual("k8s.io/apimachinery/pkg/types", "NamespacedName").Values(jen.Dict{ - jen.Id("Name"): jen.Id(fieldAccessPath).Dot("Name"), + jen.Id("Name"): jen.Id(itemFieldPath).Dot("Name"), jen.Id("Namespace"): jen.Id("resource").Dot("Namespace"), }).Dot("String").Call(), ), ), - ) - } - - return code + ), + ) } func buildFieldAccessPath(fieldPath string) string { @@ -503,11 +584,22 @@ func buildFieldAccessPath(fieldPath string) string { for i := 0; i < len(parts); i++ { part := parts[i] - // Skip "properties" and "items" keywords. Array based indexers are not supported for now - if part == "properties" || part == "items" { + // Skip "properties" keyword + if part == "properties" { continue } + // Skip "items" only if it's the schema marker (followed by "properties") + // Keep "items" if it's an actual field name (last part or followed by something other than "properties") + if part == "items" { + // Check if this is the schema marker: ".items.properties." + if i+1 < len(parts) && parts[i+1] == "properties" { + // This is the schema marker, skip it + continue + } + // Otherwise, it's a field name, keep it + } + // Capitalize the first letter accessPath = append(accessPath, capitalizeFirst(part)) } @@ -522,6 +614,49 @@ func capitalizeFirst(s string) string { return strings.ToUpper(s[:1]) + s[1:] } +// splitArrayPath splits a field path into array container and item paths. +// Returns: (beforeArray, afterArray, isArray) +// Example: "properties.spec.properties.entries.items.properties.secretRef" +// +// -> ("properties.spec.properties.entries", "properties.secretRef", true) +func splitArrayPath(fieldPath string) (string, string, bool) { + itemsPropertiesIndex := strings.Index(fieldPath, ".items.properties.") + if itemsPropertiesIndex != -1 { + beforeArray := fieldPath[:itemsPropertiesIndex] + // Skip ".items." and keep "properties." only + afterArray := fieldPath[itemsPropertiesIndex+7:] + return beforeArray, afterArray, true + } + + if strings.HasSuffix(fieldPath, ".items") { + // Remove ".items" + beforeArray := fieldPath[:len(fieldPath)-6] + return beforeArray, "", true + } + + return "", fieldPath, false +} + +func generateLoopVariableName(arrayFieldName string) string { + if arrayFieldName == "" { + return "item" + } + + name := strings.ToLower(arrayFieldName) + + if strings.HasSuffix(name, "ies") { + return name[:len(name)-3] + "y" + } + if strings.HasSuffix(name, "ses") || strings.HasSuffix(name, "ches") || strings.HasSuffix(name, "xes") { + return name[:len(name)-2] + } + if strings.HasSuffix(name, "s") { + return name[:len(name)-1] + } + + return name + "Item" +} + // buildNilCheckConditions creates a compound nil check condition for a field access path // based on which segments are required (non-pointer) vs optional (pointer). // Examples: @@ -587,6 +722,83 @@ func buildDotChain(segments []string) *jen.Statement { return stmt } +func splitRequiredSegments(fieldPath string, requiredSegments []bool) ([]bool, []bool) { + if len(requiredSegments) == 0 { + return nil, nil + } + + // Find where ".items." appears in the path + parts := strings.Split(fieldPath, ".") + arrayIndex := -1 + segmentIndex := 0 + + for _, part := range parts { + if part == "properties" || part == "items" { + if part == "items" { + arrayIndex = segmentIndex + } + continue + } + segmentIndex++ + } + + if arrayIndex == -1 || arrayIndex >= len(requiredSegments) { + return requiredSegments, nil + } + + return requiredSegments[:arrayIndex], requiredSegments[arrayIndex:] +} + +// buildNilCheckConditionsForArrayContainer creates nil checks for the array container path. +// This checks the path up to (but not including) the array itself. +func buildNilCheckConditionsForArrayContainer(arrayAccessPath string, requiredSegmentsUpToArray []bool) *jen.Statement { + segments := strings.Split(arrayAccessPath, ".") + + // If no required segments info, check only the array itself + if len(requiredSegmentsUpToArray) == 0 { + return buildDotChain(segments).Op("!=").Nil() + } + + // RequiredSegments should align with segments (excluding first "resource") + if len(requiredSegmentsUpToArray) != len(segments)-1 { + // Fallback to simple check + return buildDotChain(segments).Op("!=").Nil() + } + + var conditions *jen.Statement + + // Build nil checks for each optional segment in the path (including the array itself) + for i := 1; i < len(segments); i++ { + requiredIndex := i - 1 + + // Skip required segments + if requiredSegmentsUpToArray[requiredIndex] { + continue + } + + // Special case: Spec is always non-nil + if segments[i] == "Spec" { + continue + } + + pathSegments := segments[:i+1] + nilCheck := buildDotChain(pathSegments).Op("!=").Nil() + + if conditions == nil { + conditions = nilCheck + } else { + conditions = conditions.Op("&&").Add(nilCheck) + } + } + + // If no conditions were built (all segments are required or Spec), add check for array itself + if conditions == nil { + return buildDotChain(segments).Op("!=").Nil() + } + + return conditions +} + func generateMapFunc(f *jen.File, crdKind string, indexer IndexerInfo) { f.Func(). Id(fmt.Sprintf("New%sBy%sMapFunc", crdKind, indexer.TargetKind)). diff --git a/tools/scaffolder/internal/generate/indexers_array_test.go b/tools/scaffolder/internal/generate/indexers_array_test.go new file mode 100644 index 0000000000..bce49522f0 --- /dev/null +++ b/tools/scaffolder/internal/generate/indexers_array_test.go @@ -0,0 +1,667 @@ +// Copyright 2025 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package generate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSplitArrayPath(t *testing.T) { + tests := []struct { + name string + fieldPath string + expectedBeforeArray string + expectedAfterArray string + expectedIsArray bool + }{ + { + name: "single array", + fieldPath: "properties.spec.properties.entries.items.properties.secretRef", + expectedBeforeArray: "properties.spec.properties.entries", + expectedAfterArray: "properties.secretRef", + expectedIsArray: true, + }, + { + name: "no array", + fieldPath: "properties.spec.properties.secretRef", + expectedBeforeArray: "", + expectedAfterArray: "properties.spec.properties.secretRef", + expectedIsArray: false, + }, + { + name: "nested array", + fieldPath: "properties.entries.items.properties.nested.items.properties.ref", + expectedBeforeArray: "properties.entries", + expectedAfterArray: "properties.nested.items.properties.ref", + expectedIsArray: true, + }, + { + name: "array at root", + fieldPath: "items.properties.ref", + expectedBeforeArray: "", + expectedAfterArray: "items.properties.ref", // No ".items." just "items" at start + expectedIsArray: false, // Not detected as array + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + beforeArray, afterArray, isArray := splitArrayPath(tt.fieldPath) + assert.Equal(t, tt.expectedBeforeArray, beforeArray) + assert.Equal(t, tt.expectedAfterArray, afterArray) + assert.Equal(t, tt.expectedIsArray, isArray) + }) + } +} + +func TestGenerateLoopVariableName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"entries", "entry"}, + {"items", "item"}, + {"configs", "config"}, + {"data", "dataItem"}, + {"boxes", "box"}, // xes ending + {"policies", "policy"}, + {"matches", "match"}, + {"indexes", "index"}, + {"", "item"}, + {"ENTRIES", "entry"}, // Should convert to lowercase + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := generateLoopVariableName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseReferenceFields_ArrayReferences_Detection(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: deployments.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + replicas: + items: + properties: + secretRef: + x-kubernetes-mapping: + type: + kind: Secret + group: "" + version: v1 +spec: + group: atlas.generated.mongodb.com + names: + kind: Deployment + plural: deployments + versions: + - name: v1 + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + v20250312: + type: object + properties: + replicas: + type: array + items: + type: object + properties: + secretRef: + type: object + properties: + name: + type: string +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + refs, err := ParseReferenceFields(testFile, "Deployment") + require.NoError(t, err) + require.Len(t, refs, 1) + + ref := refs[0] + assert.Equal(t, "secretRef", ref.FieldName) + assert.Equal(t, "Secret", ref.ReferencedKind) + assert.True(t, ref.IsArrayBased, "Reference should be marked as array-based") + assert.Equal(t, "properties.spec.properties.v20250312.properties.replicas", ref.ArrayPath) + assert.Equal(t, "properties.secretRef", ref.ItemPath) + assert.Contains(t, ref.FieldPath, ".items.") +} + +func TestGenerateIndexerWithArrayReferences_Integration(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + entries: + items: + properties: + secretRef: + x-kubernetes-mapping: + type: + kind: Secret + group: "" + version: v1 +spec: + group: atlas.generated.mongodb.com + names: + kind: Cluster + plural: clusters + versions: + - name: v1 + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + v20250312: + type: object + properties: + entries: + type: array + items: + type: object + properties: + secretRef: + type: object + properties: + name: + type: string +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + outputDir := filepath.Join(tmpDir, "indexers") + err = GenerateIndexers(testFile, "Cluster", outputDir) + require.NoError(t, err) + + // Read generated file + indexerFile := filepath.Join(outputDir, "clusterbysecret.go") + assert.FileExists(t, indexerFile) + + content, err := os.ReadFile(indexerFile) + require.NoError(t, err) + contentStr := string(content) + + // Verify it contains for-loop + assert.Contains(t, contentStr, "for _, entry := range", "Should have for-loop over array") + + // Verify proper nil checks + assert.Contains(t, contentStr, "V20250312") + assert.Contains(t, contentStr, "Entries") + assert.Contains(t, contentStr, "!= nil") + + // Verify it appends keys + assert.Contains(t, contentStr, "keys = append(keys") + assert.Contains(t, contentStr, "entry.SecretRef") +} + +func TestGenerateIndexer_MixedReferences(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: deployments.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + groupRef: + x-kubernetes-mapping: + type: + kind: Group + group: atlas.generated.mongodb.com + version: v1 + replicas: + items: + properties: + secretRef: + x-kubernetes-mapping: + type: + kind: Secret + group: "" + version: v1 +spec: + group: atlas.generated.mongodb.com + names: + kind: Deployment + plural: deployments + versions: + - name: v1 + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + v20250312: + type: object + properties: + groupRef: + type: object + properties: + name: + type: string + replicas: + type: array + items: + type: object + properties: + secretRef: + type: object + properties: + name: + type: string +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + outputDir := filepath.Join(tmpDir, "indexers") + err = GenerateIndexers(testFile, "Deployment", outputDir) + require.NoError(t, err) + + // Should have generated two indexers + groupIndexer := filepath.Join(outputDir, "deploymentbygroup.go") + secretIndexer := filepath.Join(outputDir, "deploymentbysecret.go") + assert.FileExists(t, groupIndexer) + assert.FileExists(t, secretIndexer) + + // Group indexer should NOT have for-loop in Keys() method (non-array) + groupContent, err := os.ReadFile(groupIndexer) + require.NoError(t, err) + groupStr := string(groupContent) + // Keys method shouldn't have for loop (check area between "func (i" and "return keys") + assert.Contains(t, groupStr, "resource.Spec.V20250312.GroupRef") + // Verify it's not iterating over a range (the Keys method specifically) + keysMethodStart := strings.Index(groupStr, "func (i *DeploymentByGroupIndexer) Keys(") + keysMethodEnd := strings.Index(groupStr, "func NewDeploymentByGroupMapFunc") + if keysMethodStart >= 0 && keysMethodEnd >= 0 { + keysMethod := groupStr[keysMethodStart:keysMethodEnd] + assert.NotContains(t, keysMethod, "range resource", "Keys method should not iterate over array") + } + + // Secret indexer SHOULD have for-loop (array-based) + secretContent, err := os.ReadFile(secretIndexer) + require.NoError(t, err) + secretStr := string(secretContent) + // Keys method SHOULD have for loop over array + keysMethodStart = strings.Index(secretStr, "func (i *DeploymentBySecretIndexer) Keys(") + keysMethodEnd = strings.Index(secretStr, "func NewDeploymentBySecretMapFunc") + if keysMethodStart >= 0 && keysMethodEnd >= 0 { + keysMethod := secretStr[keysMethodStart:keysMethodEnd] + assert.Contains(t, keysMethod, "for _, ", "Keys method should iterate over array") + assert.Contains(t, keysMethod, ".SecretRef") // Should reference field in loop variable + } +} + +func TestSkipNestedArrayReferences(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: alerts.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + regions: + items: + properties: + notifications: + items: + properties: + secretRef: + x-kubernetes-mapping: + type: + kind: Secret +spec: + group: atlas.generated.mongodb.com + names: + kind: AlertConfig + plural: alertconfigs + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + regions: + type: array + items: + properties: + notifications: + type: array +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + outputDir := filepath.Join(tmpDir, "indexers") + err = GenerateIndexers(testFile, "AlertConfig", outputDir) + require.NoError(t, err) + + // Should not generate any indexers (nested arrays skipped) + files, err := os.ReadDir(outputDir) + if err == nil { + assert.Empty(t, files, "No indexer files should be generated for nested arrays") + } +} + +func TestSplitRequiredSegments(t *testing.T) { + tests := []struct { + name string + fieldPath string + requiredSegments []bool + expectedBeforeArray []bool + expectedInArray []bool + }{ + { + name: "simple array reference", + fieldPath: "properties.spec.properties.v20250312.properties.entries.items.properties.secretRef", + requiredSegments: []bool{false, true, false, false}, // spec, v20250312, entries, secretRef + expectedBeforeArray: []bool{false, true, false}, // up to entries + expectedInArray: []bool{false}, // secretRef within item + }, + { + name: "no array", + fieldPath: "properties.spec.properties.groupRef", + requiredSegments: []bool{false, false}, + expectedBeforeArray: []bool{false, false}, + expectedInArray: nil, + }, + { + name: "empty required segments", + fieldPath: "properties.spec.properties.entries.items.properties.ref", + requiredSegments: []bool{}, + expectedBeforeArray: nil, + expectedInArray: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + beforeArray, inArray := splitRequiredSegments(tt.fieldPath, tt.requiredSegments) + assert.Equal(t, tt.expectedBeforeArray, beforeArray) + assert.Equal(t, tt.expectedInArray, inArray) + }) + } +} + +func TestGenerateArrayFieldExtractionCode_WithOptionalFields(t *testing.T) { + // Test that array extraction code handles optional fields correctly + field := ReferenceField{ + FieldName: "secretRef", + FieldPath: "properties.spec.properties.v20250312.properties.entries.items.properties.config.properties.secretRef", + ReferencedKind: "Secret", + RequiredSegments: []bool{false, true, false, false, false}, // spec, v20250312, entries, config, secretRef + IsArrayBased: true, + ArrayPath: "properties.spec.properties.v20250312.properties.entries", + ItemPath: "properties.config.properties.secretRef", + } + + code := generateArrayFieldExtractionCode(field) + assert.NotNil(t, code) + + // Verify code was generated (can't easily inspect jen.Code) + // The integration tests will verify actual generated code +} + +func TestArrayIndexerGeneration(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: configs.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + items: + items: + properties: + secretRef: + x-kubernetes-mapping: + type: + kind: Secret +spec: + group: atlas.generated.mongodb.com + names: + kind: Config + plural: configs + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + items: + type: array + items: + properties: + secretRef: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + outputDir := filepath.Join(tmpDir, "indexers") + err = GenerateIndexers(testFile, "Config", outputDir) + require.NoError(t, err) + + indexerFile := filepath.Join(outputDir, "configbysecret.go") + content, err := os.ReadFile(indexerFile) + require.NoError(t, err) + contentStr := string(content) + + // Verify basic structure is present + assert.Contains(t, contentStr, "for _, ") + assert.Contains(t, contentStr, "range resource.Spec.V20250312") +} + +func TestArrayIndexerGenerationLoopVariable(t *testing.T) { + // Test that loop variables are generated correctly based on array field names + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: policies.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + policies: + items: + properties: + secretRef: + x-kubernetes-mapping: + type: + kind: Secret +spec: + group: atlas.generated.mongodb.com + names: + kind: Policy + plural: policies + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + v20250312: + properties: + policies: + type: array + items: + properties: + secretRef: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + outputDir := filepath.Join(tmpDir, "indexers") + err = GenerateIndexers(testFile, "Policy", outputDir) + require.NoError(t, err) + + indexerFile := filepath.Join(outputDir, "policybysecret.go") + content, err := os.ReadFile(indexerFile) + require.NoError(t, err) + contentStr := string(content) + + // Verify loop variable is "policy" (singular of "policies") + assert.Contains(t, contentStr, "for _, policy := range") + assert.Contains(t, contentStr, "policy.SecretRef") +} + +func TestArrayIndexerWithRequiredArrayContainer(t *testing.T) { + testYAML := ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: apps.atlas.generated.mongodb.com + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + properties: + entries: + items: + properties: + groupRef: + x-kubernetes-mapping: + type: + kind: Group +spec: + group: atlas.generated.mongodb.com + names: + kind: App + plural: apps + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + required: + - v20250312 + properties: + v20250312: + type: object + required: + - entries + properties: + entries: + type: array + items: + properties: + groupRef: + type: object +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.yaml") + err := os.WriteFile(testFile, []byte(testYAML), 0644) + require.NoError(t, err) + + outputDir := filepath.Join(tmpDir, "indexers") + err = GenerateIndexers(testFile, "App", outputDir) + require.NoError(t, err) + + indexerFile := filepath.Join(outputDir, "appbygroup.go") + content, err := os.ReadFile(indexerFile) + require.NoError(t, err) + contentStr := string(content) + + // Since entries is required and v20250312 is required, should check less + // The exact nil checks depend on implementation, but verify basic structure + assert.Contains(t, contentStr, "for _, entry := range") + assert.Contains(t, contentStr, "entry.GroupRef") + + // Should still check if entries != nil (even if required in schema, runtime could be nil) + nilChecks := strings.Count(contentStr, "!= nil") + assert.Greater(t, nilChecks, 0, "Should have at least some nil checks") +} diff --git a/tools/scaffolder/internal/generate/indexers_test.go b/tools/scaffolder/internal/generate/indexers_test.go index 328a3c6bc3..3682619dbc 100644 --- a/tools/scaffolder/internal/generate/indexers_test.go +++ b/tools/scaffolder/internal/generate/indexers_test.go @@ -138,7 +138,10 @@ spec: require.NoError(t, err) require.Len(t, refs, 1) + // Array references are now supported (single-level) assert.Contains(t, refs[0].FieldPath, ".items.") + assert.True(t, refs[0].IsArrayBased, "Should be marked as array-based") + assert.Equal(t, "secretRef", refs[0].FieldName) } func TestParseReferenceFields_RequiredSegments(t *testing.T) { @@ -374,7 +377,7 @@ spec: assert.Contains(t, contentStr, `"sigs.k8s.io/controller-runtime/pkg/reconcile"`) }) - t.Run("SkipArrayReferences", func(t *testing.T) { + t.Run("GenerateSingleLevelArrayIndexers", func(t *testing.T) { arrayYAML := ` apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -422,10 +425,16 @@ spec: err = GenerateIndexers(arrayFile, "AlertConfig", arrayOutputDir) require.NoError(t, err) + // Single-level arrays should now generate indexers files, err := os.ReadDir(arrayOutputDir) - if err == nil { - assert.Empty(t, files, "No indexer files should be generated for array references") - } + require.NoError(t, err) + assert.NotEmpty(t, files, "Indexer files should be generated for single-level arrays") + + // Verify the generated indexer has loop code + indexerFile := filepath.Join(arrayOutputDir, "alertconfigbysecret.go") + content, err := os.ReadFile(indexerFile) + require.NoError(t, err) + assert.Contains(t, string(content), "for _, ", "Should have for-loop for array iteration") }) }