From b6924516981c3bfa2568d22d1e954b397951ff88 Mon Sep 17 00:00:00 2001 From: Hannes Hayashi Date: Fri, 5 Jun 2026 11:30:24 +0200 Subject: [PATCH 1/2] feat: use custom type for nested object struct fields When a StringAttribute has a CustomType (e.g., jsontypes.NormalizedType{}), the AttrValue() and AttrType() methods now return the custom type's value type and type string respectively, instead of always falling back to basetypes.StringValue / basetypes.StringType{}. This fixes an inconsistency where ModelField() (for top-level structs) correctly used CustomType.ValueType(), but AttrValue() (for nested object value structs like NodeValue) did not. As a result, fields like 'props' inside nested objects were generated as basetypes.StringValue instead of jsontypes.Normalized. Changes: - Add TypeString() method to CustomTypePrimitive - Update AttrValue() and AttrType() in datasource, resource, and provider packages to check CustomType before falling back to defaults - Add unit tests for TypeString(), ValueType(), AttrType(), and AttrValue() --- internal/convert/custom_type_primitive.go | 10 ++ .../convert/custom_type_primitive_test.go | 116 ++++++++++++++++++ internal/datasource/string_attribute.go | 8 ++ internal/datasource/string_attribute_test.go | 94 ++++++++++++++ internal/provider/string_attribute.go | 8 ++ internal/resource/string_attribute.go | 8 ++ 6 files changed, 244 insertions(+) create mode 100644 internal/convert/custom_type_primitive_test.go diff --git a/internal/convert/custom_type_primitive.go b/internal/convert/custom_type_primitive.go index bf83a01a..25d02c95 100644 --- a/internal/convert/custom_type_primitive.go +++ b/internal/convert/custom_type_primitive.go @@ -91,3 +91,13 @@ func (c CustomTypePrimitive) ValueType() string { return "" } + +// TypeString returns the custom type string (e.g., "jsontypes.NormalizedType{}") +// when a custom type is specified. Returns empty string otherwise. +func (c CustomTypePrimitive) TypeString() string { + if c.customType != nil { + return c.customType.Type + } + + return "" +} diff --git a/internal/convert/custom_type_primitive_test.go b/internal/convert/custom_type_primitive_test.go new file mode 100644 index 00000000..6667d5b8 --- /dev/null +++ b/internal/convert/custom_type_primitive_test.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package convert + +import ( + "testing" + + specschema "github.com/hashicorp/terraform-plugin-codegen-spec/schema" +) + +func TestCustomTypePrimitive_TypeString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input CustomTypePrimitive + expected string + }{ + "no-custom-type": { + input: NewCustomTypePrimitive(nil, nil, "attr"), + expected: "", + }, + "custom-type": { + input: NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + nil, + "attr", + ), + expected: "jsontypes.NormalizedType{}", + }, + "associated-external-type-only": { + input: NewCustomTypePrimitive( + nil, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "attr", + ), + expected: "", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.input.TypeString() + + if got != testCase.expected { + t.Errorf("expected %q, got %q", testCase.expected, got) + } + }) + } +} + +func TestCustomTypePrimitive_ValueType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input CustomTypePrimitive + expected string + }{ + "no-custom-type": { + input: NewCustomTypePrimitive(nil, nil, "attr"), + expected: "", + }, + "custom-type": { + input: NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + nil, + "attr", + ), + expected: "jsontypes.Normalized", + }, + "associated-external-type-only": { + input: NewCustomTypePrimitive( + nil, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "attr", + ), + expected: "AttrValue", + }, + "custom-type-overrides-associated": { + input: NewCustomTypePrimitive( + &specschema.CustomType{ + ValueType: "my_custom_value", + }, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "attr", + ), + expected: "my_custom_value", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.input.ValueType() + + if got != testCase.expected { + t.Errorf("expected %q, got %q", testCase.expected, got) + } + }) + } +} diff --git a/internal/datasource/string_attribute.go b/internal/datasource/string_attribute.go index cfada81c..10d70df9 100644 --- a/internal/datasource/string_attribute.go +++ b/internal/datasource/string_attribute.go @@ -191,6 +191,10 @@ func (g GeneratorStringAttribute) AttrType(name schema.FrameworkIdentifier) (str return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil } + if ct := g.CustomType.TypeString(); ct != "" { + return ct, nil + } + return "basetypes.StringType{}", nil } @@ -200,6 +204,10 @@ func (g GeneratorStringAttribute) AttrValue(name schema.FrameworkIdentifier) str return fmt.Sprintf("%sValue", name.ToPascalCase()) } + if cv := g.CustomType.ValueType(); cv != "" { + return cv + } + return "basetypes.StringValue" } diff --git a/internal/datasource/string_attribute_test.go b/internal/datasource/string_attribute_test.go index 7f39ee37..a95aba98 100644 --- a/internal/datasource/string_attribute_test.go +++ b/internal/datasource/string_attribute_test.go @@ -14,6 +14,7 @@ import ( "github.com/doitintl/terraform-plugin-codegen-framework/internal/convert" "github.com/doitintl/terraform-plugin-codegen-framework/internal/model" + "github.com/doitintl/terraform-plugin-codegen-framework/internal/schema" ) func TestGeneratorStringAttribute_New(t *testing.T) { @@ -406,3 +407,96 @@ func TestGeneratorStringAttribute_ModelField(t *testing.T) { }) } } + +func TestGeneratorStringAttribute_AttrType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input GeneratorStringAttribute + expected string + }{ + "default": { + expected: "basetypes.StringType{}", + }, + "custom-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + nil, + "string_attribute", + ), + }, + expected: "jsontypes.NormalizedType{}", + }, + "associated-external-type": { + input: GeneratorStringAttribute{ + AssociatedExternalType: &schema.AssocExtType{}, + }, + expected: "StringAttributeType{}", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.input.AttrType("string_attribute") + + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestGeneratorStringAttribute_AttrValue(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input GeneratorStringAttribute + expected string + }{ + "default": { + expected: "basetypes.StringValue", + }, + "custom-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + nil, + "string_attribute", + ), + }, + expected: "jsontypes.Normalized", + }, + "associated-external-type": { + input: GeneratorStringAttribute{ + AssociatedExternalType: &schema.AssocExtType{}, + }, + expected: "StringAttributeValue", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.input.AttrValue("string_attribute") + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + diff --git a/internal/provider/string_attribute.go b/internal/provider/string_attribute.go index 5610f71b..1db444cd 100644 --- a/internal/provider/string_attribute.go +++ b/internal/provider/string_attribute.go @@ -191,6 +191,10 @@ func (g GeneratorStringAttribute) AttrType(name schema.FrameworkIdentifier) (str return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil } + if ct := g.CustomType.TypeString(); ct != "" { + return ct, nil + } + return "basetypes.StringType{}", nil } @@ -200,6 +204,10 @@ func (g GeneratorStringAttribute) AttrValue(name schema.FrameworkIdentifier) str return fmt.Sprintf("%sValue", name.ToPascalCase()) } + if cv := g.CustomType.ValueType(); cv != "" { + return cv + } + return "basetypes.StringValue" } diff --git a/internal/resource/string_attribute.go b/internal/resource/string_attribute.go index a1ec3c26..db90bd5f 100644 --- a/internal/resource/string_attribute.go +++ b/internal/resource/string_attribute.go @@ -229,6 +229,10 @@ func (g GeneratorStringAttribute) AttrType(name generatorschema.FrameworkIdentif return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil } + if ct := g.CustomType.TypeString(); ct != "" { + return ct, nil + } + return "basetypes.StringType{}", nil } @@ -238,6 +242,10 @@ func (g GeneratorStringAttribute) AttrValue(name generatorschema.FrameworkIdenti return fmt.Sprintf("%sValue", name.ToPascalCase()) } + if cv := g.CustomType.ValueType(); cv != "" { + return cv + } + return "basetypes.StringValue" } From f3301988bab1e77392b3a373f5ea6a3f5fc028ad Mon Sep 17 00:00:00 2001 From: Hannes Hayashi Date: Fri, 5 Jun 2026 11:56:21 +0200 Subject: [PATCH 2/2] fix: CustomType takes precedence over AssociatedExternalType in AttrType/AttrValue Swap the order of checks so CustomType is evaluated first, consistent with how Schema() and ModelField() already handle precedence. This ensures correct nested struct field types when both CustomType and AssociatedExternalType are configured. Also adds AttrType/AttrValue tests to resource and provider packages for parity with datasource, including precedence coverage. --- internal/datasource/string_attribute.go | 16 +-- internal/datasource/string_attribute_test.go | 32 +++++ internal/provider/string_attribute.go | 16 +-- internal/provider/string_attribute_test.go | 125 +++++++++++++++++++ internal/resource/string_attribute.go | 16 +-- internal/resource/string_attribute_test.go | 124 ++++++++++++++++++ 6 files changed, 305 insertions(+), 24 deletions(-) diff --git a/internal/datasource/string_attribute.go b/internal/datasource/string_attribute.go index 10d70df9..bc0af326 100644 --- a/internal/datasource/string_attribute.go +++ b/internal/datasource/string_attribute.go @@ -187,27 +187,27 @@ func (g GeneratorStringAttribute) ToFromFunctions(name string) ([]byte, error) { // AttrType returns a string representation of a basetypes.StringTypable type. func (g GeneratorStringAttribute) AttrType(name schema.FrameworkIdentifier) (string, error) { - if g.AssociatedExternalType != nil { - return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil - } - if ct := g.CustomType.TypeString(); ct != "" { return ct, nil } + if g.AssociatedExternalType != nil { + return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil + } + return "basetypes.StringType{}", nil } // AttrValue returns a string representation of a basetypes.StringValuable type. func (g GeneratorStringAttribute) AttrValue(name schema.FrameworkIdentifier) string { - if g.AssociatedExternalType != nil { - return fmt.Sprintf("%sValue", name.ToPascalCase()) - } - if cv := g.CustomType.ValueType(); cv != "" { return cv } + if g.AssociatedExternalType != nil { + return fmt.Sprintf("%sValue", name.ToPascalCase()) + } + return "basetypes.StringValue" } diff --git a/internal/datasource/string_attribute_test.go b/internal/datasource/string_attribute_test.go index a95aba98..f1a3835a 100644 --- a/internal/datasource/string_attribute_test.go +++ b/internal/datasource/string_attribute_test.go @@ -437,6 +437,22 @@ func TestGeneratorStringAttribute_AttrType(t *testing.T) { }, expected: "StringAttributeType{}", }, + "custom-type-overriding-associated-external-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "string_attribute", + ), + AssociatedExternalType: &schema.AssocExtType{}, + }, + expected: "jsontypes.NormalizedType{}", + }, } for name, testCase := range testCases { @@ -485,6 +501,22 @@ func TestGeneratorStringAttribute_AttrValue(t *testing.T) { }, expected: "StringAttributeValue", }, + "custom-type-overriding-associated-external-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "string_attribute", + ), + AssociatedExternalType: &schema.AssocExtType{}, + }, + expected: "jsontypes.Normalized", + }, } for name, testCase := range testCases { diff --git a/internal/provider/string_attribute.go b/internal/provider/string_attribute.go index 1db444cd..a4cf9b2c 100644 --- a/internal/provider/string_attribute.go +++ b/internal/provider/string_attribute.go @@ -187,27 +187,27 @@ func (g GeneratorStringAttribute) ToFromFunctions(name string) ([]byte, error) { // AttrType returns a string representation of a basetypes.StringTypable type. func (g GeneratorStringAttribute) AttrType(name schema.FrameworkIdentifier) (string, error) { - if g.AssociatedExternalType != nil { - return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil - } - if ct := g.CustomType.TypeString(); ct != "" { return ct, nil } + if g.AssociatedExternalType != nil { + return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil + } + return "basetypes.StringType{}", nil } // AttrValue returns a string representation of a basetypes.StringValuable type. func (g GeneratorStringAttribute) AttrValue(name schema.FrameworkIdentifier) string { - if g.AssociatedExternalType != nil { - return fmt.Sprintf("%sValue", name.ToPascalCase()) - } - if cv := g.CustomType.ValueType(); cv != "" { return cv } + if g.AssociatedExternalType != nil { + return fmt.Sprintf("%sValue", name.ToPascalCase()) + } + return "basetypes.StringValue" } diff --git a/internal/provider/string_attribute_test.go b/internal/provider/string_attribute_test.go index 4bde8146..69263596 100644 --- a/internal/provider/string_attribute_test.go +++ b/internal/provider/string_attribute_test.go @@ -14,6 +14,7 @@ import ( "github.com/doitintl/terraform-plugin-codegen-framework/internal/convert" "github.com/doitintl/terraform-plugin-codegen-framework/internal/model" + "github.com/doitintl/terraform-plugin-codegen-framework/internal/schema" ) func TestGeneratorStringAttribute_New(t *testing.T) { @@ -377,3 +378,127 @@ func TestGeneratorStringAttribute_ModelField(t *testing.T) { }) } } + +func TestGeneratorStringAttribute_AttrType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input GeneratorStringAttribute + expected string + }{ + "default": { + expected: "basetypes.StringType{}", + }, + "custom-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + nil, + "string_attribute", + ), + }, + expected: "jsontypes.NormalizedType{}", + }, + "associated-external-type": { + input: GeneratorStringAttribute{ + AssociatedExternalType: &schema.AssocExtType{}, + }, + expected: "StringAttributeType{}", + }, + "custom-type-overriding-associated-external-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "string_attribute", + ), + AssociatedExternalType: &schema.AssocExtType{}, + }, + expected: "jsontypes.NormalizedType{}", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.input.AttrType("string_attribute") + + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestGeneratorStringAttribute_AttrValue(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input GeneratorStringAttribute + expected string + }{ + "default": { + expected: "basetypes.StringValue", + }, + "custom-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + nil, + "string_attribute", + ), + }, + expected: "jsontypes.Normalized", + }, + "associated-external-type": { + input: GeneratorStringAttribute{ + AssociatedExternalType: &schema.AssocExtType{}, + }, + expected: "StringAttributeValue", + }, + "custom-type-overriding-associated-external-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "string_attribute", + ), + AssociatedExternalType: &schema.AssocExtType{}, + }, + expected: "jsontypes.Normalized", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.input.AttrValue("string_attribute") + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/resource/string_attribute.go b/internal/resource/string_attribute.go index db90bd5f..48307a72 100644 --- a/internal/resource/string_attribute.go +++ b/internal/resource/string_attribute.go @@ -225,27 +225,27 @@ func (g GeneratorStringAttribute) ToFromFunctions(name string) ([]byte, error) { // AttrType returns a string representation of a basetypes.StringTypable type. func (g GeneratorStringAttribute) AttrType(name generatorschema.FrameworkIdentifier) (string, error) { - if g.AssociatedExternalType != nil { - return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil - } - if ct := g.CustomType.TypeString(); ct != "" { return ct, nil } + if g.AssociatedExternalType != nil { + return fmt.Sprintf("%sType{}", name.ToPascalCase()), nil + } + return "basetypes.StringType{}", nil } // AttrValue returns a string representation of a basetypes.StringValuable type. func (g GeneratorStringAttribute) AttrValue(name generatorschema.FrameworkIdentifier) string { - if g.AssociatedExternalType != nil { - return fmt.Sprintf("%sValue", name.ToPascalCase()) - } - if cv := g.CustomType.ValueType(); cv != "" { return cv } + if g.AssociatedExternalType != nil { + return fmt.Sprintf("%sValue", name.ToPascalCase()) + } + return "basetypes.StringValue" } diff --git a/internal/resource/string_attribute_test.go b/internal/resource/string_attribute_test.go index 8ca62c35..8261e7dd 100644 --- a/internal/resource/string_attribute_test.go +++ b/internal/resource/string_attribute_test.go @@ -922,3 +922,127 @@ func TestGeneratorStringAttribute_ModelField(t *testing.T) { }) } } + +func TestGeneratorStringAttribute_AttrType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input GeneratorStringAttribute + expected string + }{ + "default": { + expected: "basetypes.StringType{}", + }, + "custom-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + nil, + "string_attribute", + ), + }, + expected: "jsontypes.NormalizedType{}", + }, + "associated-external-type": { + input: GeneratorStringAttribute{ + AssociatedExternalType: &generatorschema.AssocExtType{}, + }, + expected: "StringAttributeType{}", + }, + "custom-type-overriding-associated-external-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "string_attribute", + ), + AssociatedExternalType: &generatorschema.AssocExtType{}, + }, + expected: "jsontypes.NormalizedType{}", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.input.AttrType("string_attribute") + + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestGeneratorStringAttribute_AttrValue(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input GeneratorStringAttribute + expected string + }{ + "default": { + expected: "basetypes.StringValue", + }, + "custom-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + nil, + "string_attribute", + ), + }, + expected: "jsontypes.Normalized", + }, + "associated-external-type": { + input: GeneratorStringAttribute{ + AssociatedExternalType: &generatorschema.AssocExtType{}, + }, + expected: "StringAttributeValue", + }, + "custom-type-overriding-associated-external-type": { + input: GeneratorStringAttribute{ + CustomType: convert.NewCustomTypePrimitive( + &specschema.CustomType{ + Type: "jsontypes.NormalizedType{}", + ValueType: "jsontypes.Normalized", + }, + &specschema.AssociatedExternalType{ + Type: "*api.ExtString", + }, + "string_attribute", + ), + AssociatedExternalType: &generatorschema.AssocExtType{}, + }, + expected: "jsontypes.Normalized", + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.input.AttrValue("string_attribute") + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +}