Skip to content

Commit 42b053e

Browse files
CLOUDP-349980: tool/scaffolder: Initial commit for array-based indexers (#3017)
* Initial commit for array-based indexers * scaffolder: add support for nested arrays --------- Co-authored-by: Sergiusz Urbaniak <sergiusz.urbaniak@gmail.com>
1 parent 69b40ec commit 42b053e

File tree

3 files changed

+1031
-36
lines changed

3 files changed

+1031
-36
lines changed

tools/scaffolder/internal/generate/indexers.go

Lines changed: 248 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,34 @@ type ReferenceField struct {
4040
ReferencedGroup string
4141
ReferencedVersion string
4242
RequiredSegments []bool
43+
ArrayBoundaries []ArrayBoundary // all array boundaries (empty if not array-based)
44+
}
45+
46+
// ArrayBoundary represents a single array level in a nested array path
47+
type ArrayBoundary struct {
48+
ArrayPath string // path to the array container
49+
ItemPath string // path within array item to the next array or final field
50+
}
51+
52+
// IsArrayBased returns true if the reference is inside an array
53+
func (r ReferenceField) IsArrayBased() bool {
54+
return len(r.ArrayBoundaries) > 0
55+
}
56+
57+
// ArrayPath returns the path to the first array container (for backwards compatibility)
58+
func (r ReferenceField) ArrayPath() string {
59+
if len(r.ArrayBoundaries) == 0 {
60+
return ""
61+
}
62+
return r.ArrayBoundaries[0].ArrayPath
63+
}
64+
65+
// ItemPath returns the path within the last array item (for backwards compatibility)
66+
func (r ReferenceField) ItemPath() string {
67+
if len(r.ArrayBoundaries) == 0 {
68+
return ""
69+
}
70+
return r.ArrayBoundaries[len(r.ArrayBoundaries)-1].ItemPath
4371
}
4472

4573
type IndexerInfo struct {
@@ -223,13 +251,17 @@ func processKubernetesMapping(v map[string]any, path string, requiredSegments []
223251
reqCopy := make([]bool, len(requiredSegments))
224252
copy(reqCopy, requiredSegments)
225253

254+
// Parse all array boundaries for array support (single-level and nested)
255+
arrayBoundaries := parseAllArrayBoundaries(path)
256+
226257
ref := ReferenceField{
227258
FieldName: fieldName,
228259
FieldPath: path,
229260
ReferencedKind: kind,
230261
ReferencedGroup: group,
231262
ReferencedVersion: version,
232263
RequiredSegments: reqCopy,
264+
ArrayBoundaries: arrayBoundaries,
233265
}
234266
*references = append(*references, ref)
235267
}
@@ -328,14 +360,9 @@ func GenerateIndexers(resultPath, crdKind, indexerOutDir string) error {
328360
}
329361

330362
// Group references by target kind (e.g., all Secret refs together, all Group refs together)
331-
// Skip array-based references for now as they require iteration logic
363+
// Both single-level and nested arrays are now supported
332364
refsByKind := make(map[string][]ReferenceField)
333365
for _, ref := range references {
334-
// Skip references that are arrays for now
335-
if strings.Contains(ref.FieldPath, ".items.") {
336-
fmt.Printf("Skipping array-based reference %s in %s (array indexing not yet supported)\n", ref.FieldName, crdKind)
337-
continue
338-
}
339366
refsByKind[ref.ReferencedKind] = append(refsByKind[ref.ReferencedKind], ref)
340367
}
341368

@@ -464,36 +491,132 @@ func generateFieldExtractionCode(fields []ReferenceField) []jen.Code {
464491
code := make([]jen.Code, 0)
465492

466493
for _, field := range fields {
467-
// Build the field path from the FieldPath
468-
// FieldPath looks like: "properties.spec.properties.<version>.properties.groupRef"
469-
// We need to convert this to: resource.Spec.<version>.GroupRef
470-
fieldAccessPath := buildFieldAccessPath(field.FieldPath)
494+
// Check if this is an array-based reference
495+
if field.IsArrayBased() {
496+
code = append(code, generateArrayFieldExtractionCode(field))
497+
} else {
498+
// Original non-array logic
499+
fieldAccessPath := buildFieldAccessPath(field.FieldPath)
500+
501+
// Nil check if conditions
502+
nilCheckCondition := buildNilCheckConditions(fieldAccessPath, field.RequiredSegments)
503+
504+
// Add check that the Name field is not empty
505+
condition := nilCheckCondition.Op("&&").Add(
506+
jen.Id(fieldAccessPath).Dot("Name").Op("!=").Lit(""),
507+
)
508+
509+
// Generate: if <nil checks> && resource.Spec.<version>.GroupRef.Name != "" {
510+
// keys = append(keys, types.NamespacedName{...}.String())
511+
// }
512+
code = append(code,
513+
jen.If(condition).Block(
514+
jen.Id("keys").Op("=").Append(
515+
jen.Id("keys"),
516+
jen.Qual("k8s.io/apimachinery/pkg/types", "NamespacedName").Values(jen.Dict{
517+
jen.Id("Name"): jen.Id(fieldAccessPath).Dot("Name"),
518+
jen.Id("Namespace"): jen.Id("resource").Dot("Namespace"),
519+
}).Dot("String").Call(),
520+
),
521+
),
522+
)
523+
}
524+
}
471525

472-
// Nil check if conditions
473-
nilCheckCondition := buildNilCheckConditions(fieldAccessPath, field.RequiredSegments)
526+
return code
527+
}
474528

475-
// Add check that the Name field is not empty
476-
condition := nilCheckCondition.Op("&&").Add(
477-
jen.Id(fieldAccessPath).Dot("Name").Op("!=").Lit(""),
478-
)
529+
// generateArrayFieldExtractionCode generates code for extracting keys from array-based references.
530+
// This handles both single-level and nested arrays by generating appropriate nested loops.
531+
// Example for nested path: properties.spec.properties.regions.items.properties.notifications.items.properties.secretRef
532+
// Generates:
533+
//
534+
// if resource.Spec.Regions != nil {
535+
// for _, region := range *resource.Spec.Regions {
536+
// if region.Notifications != nil {
537+
// for _, notification := range *region.Notifications {
538+
// if notification.SecretRef != nil && notification.SecretRef.Name != "" {
539+
// keys = append(keys, ...)
540+
// }
541+
// }
542+
// }
543+
// }
544+
// }
545+
func generateArrayFieldExtractionCode(field ReferenceField) jen.Code {
546+
boundaries := field.ArrayBoundaries
547+
548+
// Build the nested structure from innermost to outermost
549+
// Start with the innermost block (the append statement)
550+
lastBoundary := boundaries[len(boundaries)-1]
551+
lastItemPath := buildFieldAccessPath(lastBoundary.ItemPath)
552+
553+
// Determine the last loop variable name (from this boundary's array)
554+
lastArrayParts := strings.Split(lastBoundary.ArrayPath, ".")
555+
lastArrayFieldName := lastArrayParts[len(lastArrayParts)-1]
556+
lastLoopVar := generateLoopVariableName(lastArrayFieldName)
557+
558+
// Build the final field path using the last loop variable
559+
finalFieldPath := strings.Replace(lastItemPath, "resource", lastLoopVar, 1)
560+
561+
// Create the innermost block: if check and keys append
562+
innermostBlock := jen.If(
563+
jen.Id(finalFieldPath).Op("!=").Nil().Op("&&").Add(
564+
jen.Id(finalFieldPath).Dot("Name").Op("!=").Lit(""),
565+
),
566+
).Block(
567+
jen.Id("keys").Op("=").Append(
568+
jen.Id("keys"),
569+
jen.Qual("k8s.io/apimachinery/pkg/types", "NamespacedName").Values(jen.Dict{
570+
jen.Id("Name"): jen.Id(finalFieldPath).Dot("Name"),
571+
jen.Id("Namespace"): jen.Id("resource").Dot("Namespace"),
572+
}).Dot("String").Call(),
573+
),
574+
)
479575

480-
// Generate: if <nil checks> && resource.Spec.<version>.GroupRef.Name != "" {
481-
// keys = append(keys, types.NamespacedName{...}.String())
482-
// }
483-
code = append(code,
484-
jen.If(condition).Block(
485-
jen.Id("keys").Op("=").Append(
486-
jen.Id("keys"),
487-
jen.Qual("k8s.io/apimachinery/pkg/types", "NamespacedName").Values(jen.Dict{
488-
jen.Id("Name"): jen.Id(fieldAccessPath).Dot("Name"),
489-
jen.Id("Namespace"): jen.Id("resource").Dot("Namespace"),
490-
}).Dot("String").Call(),
491-
),
492-
),
576+
// Now build the nested loops from innermost to outermost
577+
currentBlock := innermostBlock
578+
579+
// Process boundaries from the last down to index 1 (skip the first boundary for now)
580+
for i := len(boundaries) - 1; i >= 1; i-- {
581+
boundary := boundaries[i]
582+
prevBoundary := boundaries[i-1]
583+
584+
// Get the previous loop variable name
585+
prevArrayParts := strings.Split(prevBoundary.ArrayPath, ".")
586+
prevArrayFieldName := prevArrayParts[len(prevArrayParts)-1]
587+
prevLoopVar := generateLoopVariableName(prevArrayFieldName)
588+
589+
// Get the current loop variable name from this boundary's array
590+
arrayParts := strings.Split(boundary.ArrayPath, ".")
591+
arrayFieldName := arrayParts[len(arrayParts)-1]
592+
loopVar := generateLoopVariableName(arrayFieldName)
593+
594+
// Build the array access path from the previous boundary's ItemPath
595+
// This tells us how to get from the previous loop variable to this array
596+
arrayAccessFromPrev := buildFieldAccessPath(prevBoundary.ItemPath)
597+
arrayAccessInLoop := strings.Replace(arrayAccessFromPrev, "resource", prevLoopVar, 1)
598+
599+
// Wrap current block in a for loop with nil check
600+
currentBlock = jen.If(jen.Id(arrayAccessInLoop).Op("!=").Nil()).Block(
601+
jen.For(
602+
jen.List(jen.Id("_"), jen.Id(loopVar)).Op(":=").Range().Op("*").Id(arrayAccessInLoop),
603+
).Block(currentBlock),
493604
)
494605
}
495606

496-
return code
607+
// Handle the first/outermost boundary
608+
firstBoundary := boundaries[0]
609+
firstArrayAccessPath := buildFieldAccessPath(firstBoundary.ArrayPath)
610+
firstArrayParts := strings.Split(firstBoundary.ArrayPath, ".")
611+
firstArrayFieldName := firstArrayParts[len(firstArrayParts)-1]
612+
firstLoopVar := generateLoopVariableName(firstArrayFieldName)
613+
614+
// Wrap in the outermost nil check and for loop
615+
return jen.If(jen.Id(firstArrayAccessPath).Op("!=").Nil()).Block(
616+
jen.For(
617+
jen.List(jen.Id("_"), jen.Id(firstLoopVar)).Op(":=").Range().Op("*").Id(firstArrayAccessPath),
618+
).Block(currentBlock),
619+
)
497620
}
498621

499622
func buildFieldAccessPath(fieldPath string) string {
@@ -503,11 +626,22 @@ func buildFieldAccessPath(fieldPath string) string {
503626
for i := 0; i < len(parts); i++ {
504627
part := parts[i]
505628

506-
// Skip "properties" and "items" keywords. Array based indexers are not supported for now
507-
if part == "properties" || part == "items" {
629+
// Skip "properties" keyword
630+
if part == "properties" {
508631
continue
509632
}
510633

634+
// Skip "items" only if it's the schema marker (followed by "properties")
635+
// Keep "items" if it's an actual field name (last part or followed by something other than "properties")
636+
if part == "items" {
637+
// Check if this is the schema marker: ".items.properties."
638+
if i+1 < len(parts) && parts[i+1] == "properties" {
639+
// This is the schema marker, skip it
640+
continue
641+
}
642+
// Otherwise, it's a field name, keep it
643+
}
644+
511645
// Capitalize the first letter
512646
accessPath = append(accessPath, capitalizeFirst(part))
513647
}
@@ -522,6 +656,88 @@ func capitalizeFirst(s string) string {
522656
return strings.ToUpper(s[:1]) + s[1:]
523657
}
524658

659+
// parseAllArrayBoundaries parses a field path and returns all array boundaries.
660+
// This supports nested arrays by finding all .items.properties. patterns.
661+
// Example: "properties.spec.properties.regions.items.properties.notifications.items.properties.secretRef"
662+
// Returns: [
663+
//
664+
// {ArrayPath: "properties.spec.properties.regions", ItemPath: "properties.notifications"},
665+
// {ArrayPath: "properties.notifications", ItemPath: "properties.secretRef"}
666+
//
667+
// ]
668+
func parseAllArrayBoundaries(fieldPath string) []ArrayBoundary {
669+
var boundaries []ArrayBoundary
670+
671+
// Split by ".items.properties." to find all array boundaries
672+
const delimiter = ".items.properties."
673+
parts := strings.Split(fieldPath, delimiter)
674+
675+
if len(parts) <= 1 {
676+
// No arrays found
677+
return nil
678+
}
679+
680+
// Each part except the last represents an array container
681+
// The item path is derived from the next part
682+
for i := 0; i < len(parts)-1; i++ {
683+
arrayPath := parts[i]
684+
685+
// For nested arrays (i > 0), we need to get just the array field name
686+
// from the previous item path
687+
if i > 0 {
688+
// The arrayPath here is relative to the previous loop variable
689+
// Extract just the array field name
690+
arrayPath = "properties." + extractArrayFieldFromItemPath(parts[i])
691+
}
692+
693+
// The item path is the next part (after splitting, parts won't contain ".items.")
694+
itemPath := "properties." + parts[i+1]
695+
696+
boundaries = append(boundaries, ArrayBoundary{
697+
ArrayPath: arrayPath,
698+
ItemPath: itemPath,
699+
})
700+
}
701+
702+
return boundaries
703+
}
704+
705+
// extractArrayFieldFromItemPath extracts the array field name from an item path
706+
// For "config.properties.notifications", it returns "notifications" (the last field - the array)
707+
// For "notifications", it returns "notifications"
708+
func extractArrayFieldFromItemPath(path string) string {
709+
// Handle paths like "notifications" or "config.properties.notifications"
710+
path = strings.TrimPrefix(path, "properties.")
711+
712+
// For nested paths like "config.properties.notifications", we want the last segment (the array name)
713+
lastPropertiesIdx := strings.LastIndex(path, ".properties.")
714+
if lastPropertiesIdx >= 0 {
715+
return path[lastPropertiesIdx+len(".properties."):] // skip ".properties."
716+
}
717+
718+
return path
719+
}
720+
721+
func generateLoopVariableName(arrayFieldName string) string {
722+
if arrayFieldName == "" {
723+
return "item"
724+
}
725+
726+
name := strings.ToLower(arrayFieldName)
727+
728+
if strings.HasSuffix(name, "ies") {
729+
return name[:len(name)-3] + "y"
730+
}
731+
if strings.HasSuffix(name, "ses") || strings.HasSuffix(name, "ches") || strings.HasSuffix(name, "xes") {
732+
return name[:len(name)-2]
733+
}
734+
if strings.HasSuffix(name, "s") {
735+
return name[:len(name)-1]
736+
}
737+
738+
return name + "Item"
739+
}
740+
525741
// buildNilCheckConditions creates a compound nil check condition for a field access path
526742
// based on which segments are required (non-pointer) vs optional (pointer).
527743
// Examples:

0 commit comments

Comments
 (0)