Default values for SingleNestedAttribute block

Hello,

I am building a resource in a provider in which I need to have optional attribute blocks, but need default values when they are not supplied.

Example HCL:

resource "adguard_config" "test" {
	filtering = {
                // enabled = true|false, defaults to true
		update_interval = 1 // defaults to 24
	}
	// stats = {
	// 	enabled  = true|false, defaults to false
	// 	interval = int, defaults to 24
	// 	ignored  = set, defaults to empty set
	// }
}

Models:

type configResourceModel struct {
	ID              types.String `tfsdk:"id"`
	LastUpdated     types.String `tfsdk:"last_updated"`
	Filtering       types.Object `tfsdk:"filtering"`
	Stats           types.Object `tfsdk:"stats"`
}

type filteringModel struct {
	Enabled        types.Bool  `tfsdk:"enabled"`
	UpdateInterval types.Int64 `tfsdk:"update_interval"`
}

type statsConfigModel struct {
	Enabled  types.Bool  `tfsdk:"enabled"`
	Interval types.Int64 `tfsdk:"interval"`
	Ignored  types.Set   `tfsdk:"ignored"`
}

Schema:

func (r *configResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"id": schema.StringAttribute{
				Description: "Internal identifier for this config",
				Computed:    true,
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.UseStateForUnknown(),
				},
			},
			"last_updated": schema.StringAttribute{
				Description: "Timestamp of the last Terraform update of the config",
				Computed:    true,
			},
			"filtering": schema.SingleNestedAttribute{
				Computed: true,
				Optional: true,
				Default: objectdefault.StaticValue(types.ObjectValueMust(
					filteringModel{}.attrTypes(), filteringModel{}.defaultObject()),
				),
				Attributes: map[string]schema.Attribute{
					"enabled": schema.BoolAttribute{
						Description: "Whether DNS filtering is enabled. Defaults to `true`",
						Computed:    true,
						Optional:    true,
						Default:     booldefault.StaticBool(true),
					},
					"update_interval": schema.Int64Attribute{
						Description: "Update interval for all list-based filters, in hours. Defaults to `24`",
						Computed:    true,
						Optional:    true,
						Default:     int64default.StaticInt64(24),
						Validators: []validator.Int64{
							int64validator.OneOf([]int64{1, 12, 24, 72, 168}...),
						},
					},
				},
			},
			"stats": schema.SingleNestedAttribute{
				Computed: true,
				Optional: true,
				Default: objectdefault.StaticValue(types.ObjectValueMust(
					queryLogConfigModel{}.attrTypes(), queryLogConfigModel{}.defaultObject()),
				),
				Attributes: map[string]schema.Attribute{
					"enabled": schema.BoolAttribute{
						Description: "Whether server statistics are enabled. Defaults to `true`",
						Computed:    true,
						Optional:    true,
						Default:     booldefault.StaticBool(true),
					},
					"interval": schema.Int64Attribute{
						Description: "Time period for server statistics rotation, in hours. Defaults to `24` (1 day)",
						Computed:    true,
						Optional:    true,
						Default:     int64default.StaticInt64(24),
					},
					"ignored": schema.SetAttribute{
						Description: "List of host names which should not be counted in the server statistics",
						ElementType: types.StringType,
						Computed:    true,
						Optional:    true,
						Validators: []validator.Set{
							setvalidator.AlsoRequires(path.Expressions{
								path.MatchRelative().AtParent().AtName("enabled"),
							}...),
							setvalidator.SizeAtLeast(1),
							setvalidator.ValueStringsAre(
								stringvalidator.RegexMatches(
									regexp.MustCompile(`^[a-z0-9.-_]+$`),
									"must be a valid domain name",
								),
							),
						},
						Default: setdefault.StaticValue(
							types.SetNull(types.StringType),
						),
					},
				},
			},
		},
	}
}

As you can see, I am trying to use Default on the top-level object, for which I have some helper functions to gather the types and default values, for when the entire block is not supplied:

func (o filteringModel) attrTypes() map[string]attr.Type {
	return map[string]attr.Type{
		"enabled":         types.BoolType,
		"update_interval": types.Int64Type,
	}
}

func (o filteringModel) defaultObject() map[string]attr.Value {
	return map[string]attr.Value{
		"enabled":         types.BoolValue(true),
		"update_interval": types.Int64Value(24),
	}
}

func (o statsConfigModel) attrTypes() map[string]attr.Type {
	return map[string]attr.Type{
		"enabled":  types.BoolType,
		"interval": types.Int64Type,
		"ignored":  types.SetType{ElemType: types.StringType},
	}
}

func (o statsConfigModel) defaultObject() map[string]attr.Value {
	return map[string]attr.Value{
		"enabled":  types.BoolValue(true),
		"interval": types.Int64Value(1 * 24),
		"ignored":  basetypes.NewSetNull(types.StringType),
	}
}

The problem I am seeing is when I supply the example HCL, the Stats attribute in the plan has a null value, and I was expecting it to have the default values, per the Default directive.

What am I missing here?

Thank you

Hi @gmichels :wave:

Sorry you ran into trouble here.

The first question I have is whether the Default specified for the stats attribute is as intended? The reason I ask is that the Default that is defined is:

			"stats": schema.SingleNestedAttribute{
				Computed: true,
				Optional: true,
				Default: objectdefault.StaticValue(types.ObjectValueMust(
					queryLogConfigModel{}.attrTypes(), queryLogConfigModel{}.defaultObject()),
				),

You’ve listed out attrTypes() and defaultObject() functions for both filteringModel and statsConfigModel but I’m curious to see the attrTypes() and defaultObject() functions you have defined on queryLogConfigModel as this is what is being used in the Default on the stats attribute.

If this is as intended, can you post the code for attrTypes() and defaultObject() functions you have defined on queryLogConfigModel?

1 Like

Using a pared back provider I am seeing the expected plan and outcome from an apply with the following:

func (e *exampleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
	resp.Schema = schema.Schema{
		Attributes: map[string]schema.Attribute{
			"configurable_attribute": schema.SingleNestedAttribute{
				Optional: true,
				Computed: true,
				Default: objectdefault.StaticValue(
					types.ObjectValueMust(
						map[string]attr.Type{
							"enabled": types.BoolType,
						},
						map[string]attr.Value{
							"enabled": types.BoolValue(true),
						},
					),
				),
				Attributes: map[string]schema.Attribute{
					"enabled": schema.BoolAttribute{
						Optional: true,
						Computed: true,
						Default:  booldefault.StaticBool(true),
					},
				},
			},
			"id": schema.StringAttribute{
				Computed: true,
				PlanModifiers: []planmodifier.String{
					stringplanmodifier.UseStateForUnknown(),
				},
			},
		},
	}
}

type exampleResourceData struct {
	ConfigurableAttribute types.Object `tfsdk:"configurable_attribute"`
	Id                    types.String `tfsdk:"id"`
}

func (e *exampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
	var data exampleResourceData

	diags := req.Plan.Get(ctx, &data)
	resp.Diagnostics.Append(diags...)

	if resp.Diagnostics.HasError() {
		return
	}

	data.Id = types.StringValue("example-id")

	tflog.Trace(ctx, "created a resource")

	diags = resp.State.Set(ctx, &data)
	resp.Diagnostics.Append(diags...)
}

Using the following configuration:

resource "example_resource" "example" {
}

The output from terraform plan is as follows:

Terraform will perform the following actions:

  # example_resource.example will be created
  + resource "example_resource" "example" {
      + configurable_attribute = {
          + enabled = true
        }
      + id                     = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Ugh I can’t believe the problem was just a bad copy and paste… the resource has more attributes, I just stripped a few to make it simpler for the topic. The moment I fixed attributes with the appropriate models/types, it worked.

Thanks so much for the eagle eye @bendbennett. Hopefully this ends up serving as example for anyone else attempting to do the same default logic for nested attributes.