diff --git a/pkg/definition/defkit/cuegen.go b/pkg/definition/defkit/cuegen.go index 49937ce54..190899f1f 100644 --- a/pkg/definition/defkit/cuegen.go +++ b/pkg/definition/defkit/cuegen.go @@ -2406,8 +2406,7 @@ func (g *CUEGenerator) arrayElementToCUEWithDepth(elem *ArrayElement, depth int) if setIf, ok := op.(*SetIfOp); ok { condStr := g.conditionToCUE(setIf.Cond()) valStr := indentMultilineValue(g.valueToCUE(setIf.Value()), innerIndent+"\t") - // Convert dot-separated path to CUE shorthand syntax: "a.b.c" -> "a: b: c" - cuePath := strings.ReplaceAll(setIf.Path(), ".", ": ") + cuePath := itemFieldLabel(setIf.Path()) sb.WriteString(fmt.Sprintf("%sif %s {\n", innerIndent, condStr)) sb.WriteString(fmt.Sprintf("%s\t%s: %s\n", innerIndent, cuePath, valStr)) sb.WriteString(fmt.Sprintf("%s}\n", innerIndent)) @@ -2487,7 +2486,7 @@ func (g *CUEGenerator) arrayBuilderToCUE(ab *ArrayBuilder, depth int) string { if setIf, ok := op.(*SetIfOp); ok { condStr := g.conditionToCUE(setIf.Cond()) valStr := g.valueToCUE(setIf.Value()) - cuePath := strings.ReplaceAll(setIf.Path(), ".", ": ") + cuePath := itemFieldLabel(setIf.Path()) sb.WriteString(fmt.Sprintf("%sif %s {\n", extraIndent, condStr)) sb.WriteString(fmt.Sprintf("%s\t%s: %s\n", extraIndent, cuePath, valStr)) sb.WriteString(fmt.Sprintf("%s}\n", extraIndent)) @@ -2516,6 +2515,16 @@ func (g *CUEGenerator) arrayBuilderToCUE(ab *ArrayBuilder, depth int) string { return sb.String() } +// itemFieldLabel converts a dot-separated field path into CUE nested-field +// shorthand. Dots inside brackets are preserved as part of the key. +func itemFieldLabel(field string) string { + parts := splitPath(field) + if len(parts) < 2 { + return field + } + return strings.Join(parts, ": ") +} + // writeItemBuilderOps writes the CUE for ItemBuilder operations. func (g *CUEGenerator) writeItemBuilderOps(sb *strings.Builder, ops []itemOp, depth int) { indent := strings.Repeat(g.indent, depth) @@ -2524,7 +2533,7 @@ func (g *CUEGenerator) writeItemBuilderOps(sb *strings.Builder, ops []itemOp, de switch o := op.(type) { case setOp: valStr := g.valueToCUE(o.value) - sb.WriteString(fmt.Sprintf("%s%s: %s\n", indent, o.field, valStr)) + sb.WriteString(fmt.Sprintf("%s%s: %s\n", indent, itemFieldLabel(o.field), valStr)) case ifBlockOp: condStr := g.conditionToCUE(o.cond) @@ -2538,7 +2547,7 @@ func (g *CUEGenerator) writeItemBuilderOps(sb *strings.Builder, ops []itemOp, de case setDefaultOp: defStr := g.valueToCUE(o.defValue) - sb.WriteString(fmt.Sprintf("%s%s: *%s | %s\n", indent, o.field, defStr, o.typeName)) + sb.WriteString(fmt.Sprintf("%s%s: *%s | %s\n", indent, itemFieldLabel(o.field), defStr, o.typeName)) } } } diff --git a/pkg/definition/defkit/cuegen_test.go b/pkg/definition/defkit/cuegen_test.go index 6a651afb2..9c9a5e373 100644 --- a/pkg/definition/defkit/cuegen_test.go +++ b/pkg/definition/defkit/cuegen_test.go @@ -2749,6 +2749,117 @@ var _ = Describe("CUEGenerator", func() { Expect(cue).To(ContainSubstring("name: e.name")) Expect(cue).To(ContainSubstring("value: e.value")) }) + + It("ItemBuilder.Set expands a dotted path into nested fields", func() { + maxRead := defkit.Int("maxReadRequestUnits").Optional() + indexes := defkit.Array("indexes").WithFields( + defkit.String("name"), + ).Optional() + + c := defkit.NewComponent("table"). + Workload("v1", "ConfigMap"). + Params(indexes, maxRead). + Template(func(tpl *defkit.Template) { + arr := defkit.NewArray().ForEachWith(indexes, + func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set("name", v.Field("name")) + item.Set("onDemandThroughput.maxReadRequestUnits", maxRead) + }) + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + Set("spec.indexes", arr)) + }) + + cue := c.ToCue() + Expect(cue).To(ContainSubstring("onDemandThroughput: maxReadRequestUnits: parameter.maxReadRequestUnits")) + // The unexpanded label is not valid CUE and must not reappear. + Expect(cue).NotTo(ContainSubstring("onDemandThroughput.maxReadRequestUnits:")) + // A single-segment field is unaffected. + Expect(cue).To(ContainSubstring("name: v.name")) + Expect(parseCUE(cue)).To(Succeed()) + }) + + It("ItemBuilder.Set expands a dotted path inside a conditional block", func() { + maxRead := defkit.Int("maxReadRequestUnits").Optional() + maxWrite := defkit.Int("maxWriteRequestUnits").Optional() + indexes := defkit.Array("indexes").WithFields( + defkit.String("name"), + ).Optional() + + c := defkit.NewComponent("table"). + Workload("v1", "ConfigMap"). + Params(indexes, maxRead, maxWrite). + Template(func(tpl *defkit.Template) { + arr := defkit.NewArray().ForEachWith(indexes, + func(item *defkit.ItemBuilder) { + item.If(maxRead.IsSet(), func() { + item.Set("onDemandThroughput.maxReadRequestUnits", maxRead) + }) + item.If(maxWrite.IsSet(), func() { + item.Set("onDemandThroughput.maxWriteRequestUnits", maxWrite) + }) + }) + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + Set("spec.indexes", arr)) + }) + + cue := c.ToCue() + // Sibling writes to the same parent unify into one struct. + Expect(cue).To(ContainSubstring("onDemandThroughput: maxReadRequestUnits: parameter.maxReadRequestUnits")) + Expect(cue).To(ContainSubstring("onDemandThroughput: maxWriteRequestUnits: parameter.maxWriteRequestUnits")) + Expect(parseCUE(cue)).To(Succeed()) + }) + + It("ItemBuilder.Set does not split on a dot inside a bracketed key", func() { + items := defkit.Array("items").WithFields( + defkit.String("value"), + ).Optional() + + c := defkit.NewComponent("annotated"). + Workload("v1", "ConfigMap"). + Params(items). + Template(func(tpl *defkit.Template) { + arr := defkit.NewArray().ForEachWith(items, + func(item *defkit.ItemBuilder) { + v := item.Var() + item.Set(`metadata.annotations["example.com/owner"]`, v.Field("value")) + }) + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + Set("spec.items", arr)) + }) + + cue := c.ToCue() + // The dot inside the brackets is part of the key, not a separator. + // Bracketed keys are not otherwise supported here; this only guards + // against one being split apart. + Expect(cue).To(ContainSubstring(`annotations["example.com/owner"]: v.value`)) + Expect(cue).NotTo(ContainSubstring(`example: com/owner`)) + }) + + It("ArrayElement.SetIf preserves dots inside bracketed keys", func() { + enabled := defkit.Bool("enabled").Optional() + items := defkit.Array("items").WithFields( + defkit.String("value"), + ).Optional() + + c := defkit.NewComponent("annotated"). + Workload("v1", "ConfigMap"). + Params(enabled, items). + Template(func(tpl *defkit.Template) { + const path = `labels["app.kubernetes.io/name"]` + arr := defkit.NewArray(). + Item(defkit.NewArrayElement(). + SetIf(enabled.IsSet(), path, defkit.Lit("static"))). + ForEach(items, defkit.NewArrayElement(). + SetIf(enabled.IsSet(), path, defkit.Reference("m.value"))) + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + Set("spec.items", arr)) + }) + + cue := c.ToCue() + Expect(strings.Count(cue, `labels["app.kubernetes.io/name"]:`)).To(Equal(2)) + Expect(cue).NotTo(ContainSubstring(`labels["app: kubernetes: io/name"]:`)) + }) }) Describe("From().Filter().Map().Dedupe() pipeline", func() { diff --git a/pkg/definition/defkit/defkit_suite_test.go b/pkg/definition/defkit/defkit_suite_test.go index bdded2135..729dd92be 100644 --- a/pkg/definition/defkit/defkit_suite_test.go +++ b/pkg/definition/defkit/defkit_suite_test.go @@ -19,6 +19,7 @@ package defkit_test import ( "testing" + "cuelang.org/go/cue/parser" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -27,3 +28,11 @@ func TestDefkit(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Defkit Suite") } + +// parseCUE parses generated CUE and returns any syntax error. Substring +// assertions cannot tell a well-formed definition from one that only breaks +// once a parser reaches it. +func parseCUE(src string) error { + _, err := parser.ParseFile("generated.cue", src) + return err +}