Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/crossplane/composition/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func (c *generateCmd) AfterApply(cfg *config.Config) error {
generator.AllLanguages(
generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors),
generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects),
generator.WithGoRequiredObjectFields(cfg.Features.GenerateGoRequiredObjectFields),
),
proj.Spec.Schemas.GetLanguages(),
)),
Expand Down
9 changes: 9 additions & 0 deletions cmd/crossplane/config/help/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,12 @@ generated Go models (off by default), so you can register generated types with a
```shell
crossplane config set features.generateGoRuntimeObjects true
```

Generate a required object-typed property (for example, a managed resource's
`spec.forProvider`) on generated Go models as a non-pointer value instead of a
pointer (off by default; this is a breaking change for code that constructs
such a field as a pointer or nil-checks it):

```shell
crossplane config set features.generateGoRequiredObjectFields true
```
9 changes: 5 additions & 4 deletions cmd/crossplane/config/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ type boolSetter func(c *config.Config, v bool)
//
//nolint:gochecknoglobals // This is a constant.
var boolKeys = map[string]boolSetter{
"features.enableAlpha": func(c *config.Config, v bool) { c.Features.EnableAlpha = v },
"features.disableBeta": func(c *config.Config, v bool) { c.Features.DisableBeta = v },
"features.generateGoModelAccessors": func(c *config.Config, v bool) { c.Features.GenerateGoModelAccessors = v },
"features.generateGoRuntimeObjects": func(c *config.Config, v bool) { c.Features.GenerateGoRuntimeObjects = v },
"features.enableAlpha": func(c *config.Config, v bool) { c.Features.EnableAlpha = v },
"features.disableBeta": func(c *config.Config, v bool) { c.Features.DisableBeta = v },
"features.generateGoModelAccessors": func(c *config.Config, v bool) { c.Features.GenerateGoModelAccessors = v },
"features.generateGoRuntimeObjects": func(c *config.Config, v bool) { c.Features.GenerateGoRuntimeObjects = v },
"features.generateGoRequiredObjectFields": func(c *config.Config, v bool) { c.Features.GenerateGoRequiredObjectFields = v },
}

func (c *setCmd) AfterApply() error {
Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/dependency/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func (c *addCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con
generator.AllLanguages(
generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors),
generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects),
generator.WithGoRequiredObjectFields(cfg.Features.GenerateGoRequiredObjectFields),
),
proj.Spec.Schemas.GetLanguages(),
)),
Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/dependency/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func (c *updateCacheCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter,
generator.AllLanguages(
generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors),
generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects),
generator.WithGoRequiredObjectFields(cfg.Features.GenerateGoRequiredObjectFields),
),
proj.Spec.Schemas.GetLanguages(),
)),
Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/function/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error
generator.AllLanguages(
generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors),
generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects),
generator.WithGoRequiredObjectFields(cfg.Features.GenerateGoRequiredObjectFields),
),
c.proj.Spec.Schemas.GetLanguages(),
),
Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/project/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func (c *buildCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *c
generator.AllLanguages(
generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors),
generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects),
generator.WithGoRequiredObjectFields(cfg.Features.GenerateGoRequiredObjectFields),
),
c.proj.Spec.Schemas.GetLanguages(),
)
Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/project/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *con
generator.AllLanguages(
generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors),
generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects),
generator.WithGoRequiredObjectFields(cfg.Features.GenerateGoRequiredObjectFields),
),
c.proj.Spec.Schemas.GetLanguages(),
)
Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/render/op/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal
generators := generator.AllLanguages(
generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors),
generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects),
generator.WithGoRequiredObjectFields(cfg.Features.GenerateGoRequiredObjectFields),
)

depMgr := dependency.NewManager(proj, projFS,
Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/render/xr/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ func (c *Cmd) loadFunctionsFromProject(ctx context.Context, log logging.Logger,
generators := generator.AllLanguages(
generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors),
generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects),
generator.WithGoRequiredObjectFields(cfg.Features.GenerateGoRequiredObjectFields),
)

depMgr := dependency.NewManager(proj, projFS,
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ type Features struct {
// per-package AddToScheme helpers on generated Go models. Disabled by
// default; opt in to register generated types with a runtime.Scheme.
GenerateGoRuntimeObjects bool `json:"generateGoRuntimeObjects,omitempty"`

// GenerateGoRequiredObjectFields enables generating a required
// object-typed property (e.g. a managed resource's spec.forProvider) as a
// non-pointer value instead of the all-pointer/all-optional default.
// Disabled by default; it's a breaking change for any consumer that
// constructs such a field as a pointer or nil-checks it.
GenerateGoRequiredObjectFields bool `json:"generateGoRequiredObjectFields,omitempty"`
}

// Load reads a Config from path. A missing file is not an error; the zero
Expand Down
93 changes: 83 additions & 10 deletions internal/schemas/generator/accessors.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ func addAccessors(code string) (string, error) {
// duplicate method would make the package fail to compile.
existing := collectExistingMethods(f)

// writeStructAccessors needs to know which fields are non-pointer struct
// values (required object-typed fields, see goRemoveRequired in go.go),
// so it can still return/accept a pointer for them.
structs := collectStructTypes(f)

var b strings.Builder
// Walk declarations in source order so the generated output is stable.
for _, decl := range f.Decls {
Expand All @@ -81,7 +86,7 @@ func addAccessors(code string) (string, error) {
if !ok || st.Fields == nil {
continue
}
writeStructAccessors(&b, fset, receiverTypeExpr(ts), st, existing[ts.Name.Name])
writeStructAccessors(&b, fset, receiverTypeExpr(ts), st, existing[ts.Name.Name], structs)
}
}

Expand Down Expand Up @@ -201,7 +206,7 @@ func isNilable(e ast.Expr) bool {
// (e.g. a field named PasswordData alongside a sibling field named
// GetPasswordData): Go forbids a method and a field sharing a name on the same
// type, and Terraform schemas occasionally produce exactly that pair.
func writeStructAccessors(b *strings.Builder, fset *token.FileSet, typeName string, st *ast.StructType, skip map[string]bool) {
func writeStructAccessors(b *strings.Builder, fset *token.FileSet, typeName string, st *ast.StructType, skip map[string]bool, structs map[string]bool) {
fieldNames := collectFieldNames(st)

for _, field := range st.Fields.List {
Expand All @@ -216,23 +221,55 @@ func writeStructAccessors(b *strings.Builder, fset *token.FileSet, typeName stri
// a node we just parsed; skip defensively rather than panic.
continue
}
fieldType := typ.String()

shape := fieldAccessorShape{
fieldType: typ.String(),
isValueStruct: isValueStructField(field.Type, structs),
isNilable: isNilable(field.Type),
skip: skip,
siblingFields: fieldNames,
}
for _, name := range field.Names {
// Skip unexported fields: an accessor for them would be useless to
// external consumers and could produce oddly-cased method names.
// Generated models don't currently have any, but guard defensively.
if !name.IsExported() {
continue
}
writeFieldAccessors(b, typeName, name.Name, shape)
}
}
}

fieldName := name.Name
if !skip["Get"+fieldName] && !fieldNames["Get"+fieldName] {
writeGetter(b, typeName, fieldName, fieldType, isNilable(field.Type))
}
if !skip["Set"+fieldName] && !fieldNames["Set"+fieldName] {
writeSetter(b, typeName, fieldName, fieldType)
}
// fieldAccessorShape carries the per-field-declaration context
// writeFieldAccessors needs, so its signature stays small as more shapes
// (see isValueStructField) are added.
type fieldAccessorShape struct {
fieldType string
isValueStruct bool
isNilable bool
skip map[string]bool
siblingFields map[string]bool
}

// writeFieldAccessors appends the getter/setter pair for one field, in
// either the value-struct shape (see isValueStructField) or the default
// pointer shape. An accessor is omitted if its name is already claimed in
// shape.skip (an oapi-codegen-generated method) or shape.siblingFields (a
// same-named sibling field; see writeStructAccessors).
func writeFieldAccessors(b *strings.Builder, typeName, fieldName string, shape fieldAccessorShape) {
if !shape.skip["Get"+fieldName] && !shape.siblingFields["Get"+fieldName] {
if shape.isValueStruct {
writeValueStructGetter(b, typeName, fieldName, shape.fieldType)
} else {
writeGetter(b, typeName, fieldName, shape.fieldType, shape.isNilable)
}
}
if !shape.skip["Set"+fieldName] && !shape.siblingFields["Set"+fieldName] {
if shape.isValueStruct {
writeValueStructSetter(b, typeName, fieldName, shape.fieldType)
} else {
writeSetter(b, typeName, fieldName, shape.fieldType)
}
}
}
Expand Down Expand Up @@ -283,3 +320,39 @@ func writeSetter(b *strings.Builder, typeName, fieldName, fieldType string) {
b.WriteString("\t" + accessorReceiver + "." + fieldName + " = v\n")
b.WriteString("}\n")
}

// isValueStructField reports whether typ is a bare reference to a known
// local struct — the shape a required object-typed field gets (see
// goRemoveRequired), unlike every other field, which is a pointer.
func isValueStructField(typ ast.Expr, structs map[string]bool) bool {
id, ok := typ.(*ast.Ident)
return ok && structs[id.Name]
}

// writeValueStructGetter appends a getter for a required object-typed field
// (a non-pointer value, see isValueStructField). Returns a pointer so
// callers can chain getters like every other field, and nil on a nil
// receiver instead of panicking.
func writeValueStructGetter(b *strings.Builder, typeName, fieldName, fieldType string) {
b.WriteString("\n// Get" + fieldName + " returns a pointer to the " + fieldName + " field.\n")
b.WriteString("// It returns nil if the receiver is nil.\n")
b.WriteString("func (" + accessorReceiver + " *" + typeName + ") Get" + fieldName + "() *" + fieldType + " {\n")
b.WriteString("\tif " + accessorReceiver + " == nil {\n")
b.WriteString("\t\treturn nil\n")
b.WriteString("\t}\n")
b.WriteString("\treturn &" + accessorReceiver + "." + fieldName + "\n")
b.WriteString("}\n")
}

// writeValueStructSetter appends a setter for a required object-typed
// field. Takes a pointer to mirror the getter so SetX(GetX()) round-trips,
// and no-ops on nil since the field can only be replaced, not unset.
func writeValueStructSetter(b *strings.Builder, typeName, fieldName, fieldType string) {
b.WriteString("\n// Set" + fieldName + " sets the " + fieldName + " field from v. It does nothing if v is nil.\n")
b.WriteString("func (" + accessorReceiver + " *" + typeName + ") Set" + fieldName + "(v *" + fieldType + ") {\n")
b.WriteString("\tif v == nil {\n")
b.WriteString("\t\treturn\n")
b.WriteString("\t}\n")
b.WriteString("\t" + accessorReceiver + "." + fieldName + " = *v\n")
b.WriteString("}\n")
}
76 changes: 76 additions & 0 deletions internal/schemas/generator/accessors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,82 @@ type FooAlias = Foo
}
}

// TestAddAccessorsValueStructField verifies a non-pointer struct field (the
// shape goRemoveRequired gives a required object-typed property) still gets
// a pointer-shaped Get/Set pair, so callers can chain getters and round-trip
// SetX(GetX()) like every other field.
func TestAddAccessorsValueStructField(t *testing.T) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cases := map[string]struct {
args string
want map[string]string
reason string
}{
"ValueStructField": {
args: `package v1alpha1

type Foo struct {
Bar BarValue ` + "`json:\"bar\"`" + `
}

type BarValue struct {
Count *int64 ` + "`json:\"count,omitempty\"`" + `
}
`,
want: map[string]string{
"Foo.GetBar": "*BarValue",
"Foo.SetBar": "*BarValue",
"BarValue.GetCount": "*int64",
"BarValue.SetCount": "*int64",
},
reason: "a non-pointer struct field still gets a pointer-shaped Get/Set pair",
},
"ValueStructFieldViaAlias": {
args: `package v1alpha1

type Foo struct {
Bar BarAlias ` + "`json:\"bar\"`" + `
}

type RealBar struct {
Count *int64 ` + "`json:\"count,omitempty\"`" + `
}

type BarAlias = RealBar
`,
want: map[string]string{
"Foo.GetBar": "*BarAlias",
"Foo.SetBar": "*BarAlias",
"RealBar.GetCount": "*int64",
"RealBar.SetCount": "*int64",
},
reason: "a field typed by a component-name alias (oapi-codegen's x-go-type-name pattern) is still recognized as the local struct it names",
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got, err := addAccessors(tc.args)
if err != nil {
t.Fatalf("addAccessors returned error: %v", err)
}

if diff := cmp.Diff(tc.want, collectMethods(t, got)); diff != "" {
t.Errorf("generated accessors (-want +got, %s):\n%s", tc.reason, diff)
}

// The round-trip pattern every other field supports must still
// type-check for a value-struct field.
roundTrip := got + `

func roundTrip(f *Foo) {
f.SetBar(f.GetBar())
}
`
typeCheck(t, roundTrip)
})
}
}

// guardsNilReceiver reports whether the body of method recv.name opens with an
// `if <receiver> == nil` guard.
func guardsNilReceiver(t *testing.T, src, recv, name string) bool {
Expand Down
Loading