Defining `tschema.SetNestedAttribute` in resource model

I saw at the providers-plugin-framework tutorial it’s possible to define a schema.ListNestedAttribute as array of predefine struct in the resource model (orderItemModel).
Is there an equivalent way to handle Sets of NestedAttribute?
My case -

"metric_fields": schema.SetNestedAttribute{
				NestedObject: schema.NestedAttributeObject{
					Attributes: map[string]schema.Attribute{
						"target_base_metric_name": schema.StringAttribute{...},
						"source_field": schema.StringAttribute{...},
						"aggregations": schema.SingleNestedAttribute{...},
					},
				},
			},

So as you can imagine, handle it like was suggested here can be painful…

Hi again, @OrNovo :wave: Thank you for raising this topic.

The maintainers are looking to make the framework website documentation more prescriptive about data handling of each type (:+1: voting is always appreciated for prioritization), but let’s see if we can get you heading in the right direction here.

I saw at the providers-plugin-framework tutorial it’s possible to define a schema.ListNestedAttribute as array of predefine struct in the resource model ([]orderItemModel).
Is there an equivalent way to handle Sets of NestedAttribute?

Before I dive into answering this question it might be helpful to describe the background of the problem and the framework’s intended design. Terraform’s type system has concepts outside of Go’s built-in type system, such as unknown values and sensitive values. While only unknown values are directly exposed to providers today, provider code should generally be written defensively so that the provider won’t run into situations where the handling of Terraform’s data (via the framework type system and helpers) cannot accept or represent values accurately. The framework’s types package (aliases to types/basetypes package types) handles these underlying details for you, which is why they are generally recommended over Go’s built-in types. Those framework types can also be extended via the custom types functionality for more advanced use cases.

The provider developer approach of writing “data model” types, such as:

type ExampleModel struct {
  ExampleAttribute T `tfsdk:"example_attribute"`
}

Is mainly used as a methodology to standardize data handling (reading and writing) across resource logic, however it is not the only possible approach. It is generally just a recommendation so data handling logic, such as converting to/from other Go types, can be directly associated with the data via methods on the type. It also enables the framework to provide additional correctness checks to ensure all attributes/fields are properly handled.

While it is may be necessary to handle reading Terraform’s data in an accurate manner, such as accounting for concepts such as unknown values, the framework is more lenient with how to write Terraform data based on these Go built-in type conversion rules. It is not required to read and write data using the suggested data model approach, however it generally makes the provider code overall easier to maintain.

Most importantly though, the framework does require tfsdk struct field tags to automatically convert between Go struct types and schema objects since that is the only way it knows how to map Go struct fields to object attribute names. Otherwise, objects would need to be manually created/mapped via Go logic. Working with nested attributes/blocks greatly increases the complexity of data handling because it requires dealing with the intermediate nested object. Dealing with objects tends to be easier with the data model approach with struct field tags over manually writing that field mapping Go logic.

So all that said, in this particular case:

"metric_fields": schema.SetNestedAttribute{
	NestedObject: schema.NestedAttributeObject{
		Attributes: map[string]schema.Attribute{
			"target_base_metric_name": schema.StringAttribute{...},
			"source_field": schema.StringAttribute{...},
			"aggregations": schema.SingleNestedAttribute{...},
		},
	},
},

Can be thought in the framework’s type system as a set of objects with three attributes (two strings and one object). The attribute type documentation might be helpful in this case to map these to types types (until the documentation is expanded as mentioned above). Each “layer” of the data becomes a conversion point, where you have the choice whether to use a Go built-in type or types type, although the preference should generally be towards a types type.

A representative data model for the framework might be a type for each “layer”:

type ExampleResourceModel struct {
  MetricFields types.Set `tfsdk:"metric_fields"`
}

type MetricFieldModel struct {
  Aggregations         types.Object `tfsdk:"aggregations"`
  SourceField          types.String `tfsdk:"source_field"`
  TargetBaseMetricName types.String `tfsdk:"target_base_metric_name"`
}

type AggregationsModel struct {/* ... */}

Where the logic (in say a resource Create method) might be:

// This example ignores diagnostics handling for brevity
var data ExampleResourceModel

// Read Terraform's plan data into the data variable
req.Plan.Get(ctx, &data)

// data.MetricFields is contains a type.Set value now
// We know that each set element is an object from the schema

// API calls, setting data, etc.

resp.State.Set(ctx, &data)

The problem becomes “how do I get that data into other Go types” (generally associated with an SDK for the vendor API, etc.)? This is where code across differing providers goes into their own directions. Generally, developers will write methods on these model types to convert to/from those other Go types, but that is just one approach.

One method-based approach using (types.Set).ElementsAs() / (types.Object).As() may look like:

// illustrative of types from another SDK, such as the API's
type OtherType struct {
  MetricFields []OtherTypeMetricField
}

type OtherTypeMetricField struct {
  Aggregations         OtherTypeAggregations
  SourceField          *string
  TargetBaseMetricName *string
}

type OtherTypeAggregations struct {/* ... */}

func (m ExampleResourceModel) ToOtherType(ctx context.Context) (OtherType, diag.Diagnostics) {
  var diags diag.Diagnostics
  var result OtherType

  // types.Object is the default value type, but it could be its own custom type
  // which enables skipping the intermediate type representations below
  var metricFieldObjects []types.Object

  diags.Append(m.MetricFields.ElementsAs(ctx, &metricFieldObjects)...)

  if diags.HasError() {
    return result, diags
  }

  for _, metricFieldObject := range metricFieldObjects {
    var metricFieldModel MetricFieldModel

    diags.Append(metricFieldObject.As(ctx, &metricFieldModel, basetypes.ObjectAsOptions{})...)

    if diags.HasError() {
      return result, diags
    }

    metricField, metricFieldDiags := metricFieldModel.ToOtherTypeMetricField(ctx)

    diags.Append(metricFieldDiags...)

    if diags.HasError() {
      return result, diag
    }

    result.MetricFields = append(result.MetricFields, metricsField)
  }

  return result, diags
}

func (m MetricFieldModel) ToOtherTypeMetricField(ctx context.Context) (OtherTypeMetricField, diag.Diagnostics) {
  var diags diag.Diagnostics
  var result OtherTypeMetricField

  var aggregationsModel AggregationsModel

  diags.Append(m.Aggregations.As(ctx, &aggregationsModel, basetypes.ObjectAsOptions{})...)

  if diags.HasError() {
    return result, diags
  }

  aggregations, aggregationsDiags := aggregationsModel.ToOtherTypeAggregations(ctx)

  diags.Append(aggregationsDiags...)

  if diags.HasError() {
    return result, diags
  }

  result.Aggregations = aggregations
  result.SourceField = m.SourceField.ValueStringPointer()
  result.TargetBaseMetricName = m.TargetBaseMetricName.ValueStringPointer()

  return result, diags
}

func (m AggregationsModel) ToOtherTypeAggregations(ctx context.Context) (OtherTypeAggregations, diag.Diagnostics) {/* ... */}

There are also other coding patterns possible, which have their own tradeoffs between simplicity, composability, and data accuracy.

For an illustrative example, this is perfectly valid way to write state data for an attribute containing a set of strings:

resp.State.SetAttribute(
  ctx,
  path.Root("some_set_of_strings_attribute"),
  []string{"Happy", "Terraforming"}, // could be directly from API field, etc.
)

Expanding on that idea, technically you use an inline struct definition to handle the implementation details the framework needs for objects:

resp.State.SetAttribute(
  ctx,
  path.Root("metric_fields"),
  []struct{
    Aggregations         struct{/* ... */} `tfsdk:"aggregations"`
    SourceField          string `tfsdk:"source_field"`
    TargetBaseMetricName string `tfsdk:"target_base_metric_name"`
  }{
    {
      Aggregations: struct{/* ... */}{/* ... */}
      SourceField: "phew",
      TargetBaseMetricName: "oh my",
    },
  },
)

In practice though, this approach is very verbose and the definition is generally needed across multiple pieces of logic anyways, so it is easiest to encapsulate it in a proper type definition and once there is a type it becomes easy to associate logic onto the type itself via methods.

If you are looking for a more precise example for your use case or further discussion on this topic, it’d be helpful to know the overall goal of what you are trying to do and any associated Go type definitions. If you have a feature proposal for how this can be simplified in the framework, please feel free to create an issue in the framework repository. :+1:


Lastly, since you mention this data handling is painful, it is probably worth mentioning that creating helpers or using code generation can really help in this case. The process of going from schema definition to data handling types/logic is very consistent when using types types. The framework maintainers are working in this space to enable a few “define it once” approaches, which may become public later this year, but in the meantime, it might be useful to think whether creating data handling helpers in your provider code can simplify this sort of repetitive code. The framework was designed to be a fairly accurate/detailed representative of the provider side of the Terraform plugin protocol, which can be verbose in places, but that verbosity also enables it to be quite extensible via standard Go coding and generation techniques.

1 Like

@bflad Thanks for the fast and detailed response!