Skip to content

query: update ExpectLength behaviour to support multiple list blocks#597

Closed
stephybun wants to merge 1 commit intomainfrom
b/support-for-each-list-blocks
Closed

query: update ExpectLength behaviour to support multiple list blocks#597
stephybun wants to merge 1 commit intomainfrom
b/support-for-each-list-blocks

Conversation

@stephybun
Copy link
Member

@stephybun stephybun commented Jan 29, 2026

Related Issue

Resource Test Config

resource "parent" "test1" {
  name = "parent1"
}

resource "parent" "test2" {
  name = "parent2"
}

resource "child" "test1" {
  count = 3

  name        = "child1-${count.index}"
  parent_name = parent.test1.name
}

resource "child" "test2" {
  count = 2

  name        = "child2-${count.index}"
  parent_name = parent.test2.name
}

List Test Config

list "parent" "test" {
  provider = thing

  config {
    some_grouping = "I am grandparent"
  }
}

list "child" "test" {
  for_each = toset([for parent in list.parent.test.data : parent.state.name])

  provider = thing

  config {
    parent_name = each.key
  }
}

Failing ExpectLength Checks

querycheck.ExpectLength("parent.test", 2) // compares against a count of 3 and fails
querycheck.ExpectLength("child.test", 5) // compares against a count of 3 and fails

Example derived from https://github.com/hashicorp/terraform-provider-azurerm/blob/527c291f1dd717cab9bc7c36bfc8a4602b3f7708/internal/services/network/subnet_resource_list_test.go

Description

This PR proposes how we could update the behaviour of the ExpectLength check without causing disruption through a breaking change or through the replacement of this check.

The config examples above highlight several shortcomings in the current design of the ExpectLength querycheck

  1. Users cannot construct a query test that can check the expected number of found resources for different types of list resources
  2. Multiple list resources of the same type that are created dynamically with for_each cannot be reliably checked

Several changes have been made to support both use cases, the second case in particular will likely be a highly common and popular way for users to query child resources.

I've chosen to deprecate the existing QuerySummary property in the CheckQueryRequest and introduced a QuerySummaries that will collect all available tjson.ListCompleteMessages from the query command.

The ExpectLength check has been updated to find the correct summary based on the provided resource address for validating the count.

Furthermore we would need to provide a means for providers to indicate that we're computing the sum of multiple summaries, thus the suggestion to support the addition of a trailing * on the resource address specified in the query check e.g. querycheck.ExpectLength("child.test*", 5)

Whilst it isn't the most robust solution, it's a non-breaking option.

Rollback Plan

  • If a change needs to be reverted, we will roll out an update to the code within 7 days.

Changes to Security Controls

Are there any changes to security controls (access controls, encryption, logging) in this pull request? If so, explain.

// specifying a trailing '*' to indicate that we should be looking for multiple summaries

if !strings.HasSuffix(e.resourceAddress, "*") {
if strings.EqualFold(strings.TrimPrefix(summary.Address, "list."), e.resourceAddress) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change might affect some users.

Although the documentation examples for this check specify the resource address as some_resource.test users can specify list.some_resource.test without affecting the outcome of the result (although arguably the check was behaving incorrectly to begin with). Now users need to ensure that they're not specifying the list. prefix in the resource address.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very interesting 🤔 , the reverse (referencing list.<name-of-list-resource>.<alias>) actually makes more sense to me initially. Since you're asserting the list results itself, not the outputted resource type / alias (which technically, wouldn't have an alias until config has been generated)

}
`,
QueryResultChecks: []querycheck.QueryResultCheck{
querycheck.ExpectLength("examplecloud_containerette.test*", 12),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not read the entirety of this test suite, but why 12? Is that correct? Or is it wrong on purpose?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it's because examplecloud_containerette.test* is being used to match:

  • list.examplecloud_containerette.test == 6 instances
  • list.examplecloud_containerette.test2 == 6 instances

The confusing bit is that the resource config above doesn't really impact the list results, since they are just hardcoded in examplecloudListResource():

push(list.ListResult{
Resource: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
"location": tftypes.String,
"name": tftypes.String,
"resource_group_name": tftypes.String,
"instances": tftypes.Number,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "foo/banane"),
"location": tftypes.NewValue(tftypes.String, "westeurope"),
"name": tftypes.NewValue(tftypes.String, "banane"),
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"instances": tftypes.NewValue(tftypes.Number, 5),
},
)),
Identity: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"resource_group_name": tftypes.String,
"name": tftypes.String,
},
},
map[string]tftypes.Value{
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"name": tftypes.NewValue(tftypes.String, "banane"),
},
)),
DisplayName: "banane",
})
push(list.ListResult{
Resource: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
"location": tftypes.String,
"name": tftypes.String,
"resource_group_name": tftypes.String,
"instances": tftypes.Number,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "foo/ananas"),
"location": tftypes.NewValue(tftypes.String, "westeurope"),
"name": tftypes.NewValue(tftypes.String, "ananas"),
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"instances": tftypes.NewValue(tftypes.Number, 9000),
},
)),
Identity: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"resource_group_name": tftypes.String,
"name": tftypes.String,
},
},
map[string]tftypes.Value{
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"name": tftypes.NewValue(tftypes.String, "ananas"),
},
)),
DisplayName: "ananas",
})
push(list.ListResult{
Resource: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
"location": tftypes.String,
"name": tftypes.String,
"resource_group_name": tftypes.String,
"instances": tftypes.Number,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "foo/kiwi"),
"location": tftypes.NewValue(tftypes.String, "westeurope"),
"name": tftypes.NewValue(tftypes.String, "kiwi"),
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"instances": tftypes.NewValue(tftypes.Number, 88),
},
)),
Identity: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"resource_group_name": tftypes.String,
"name": tftypes.String,
},
},
map[string]tftypes.Value{
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"name": tftypes.NewValue(tftypes.String, "kiwi"),
},
)),
DisplayName: "kiwi",
})
push(list.ListResult{
Resource: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
"location": tftypes.String,
"name": tftypes.String,
"resource_group_name": tftypes.String,
"instances": tftypes.Number,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "bar/papaya"),
"location": tftypes.NewValue(tftypes.String, "westeurope"),
"name": tftypes.NewValue(tftypes.String, "banane"),
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"instances": tftypes.NewValue(tftypes.Number, 3),
},
)),
Identity: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"resource_group_name": tftypes.String,
"name": tftypes.String,
},
},
map[string]tftypes.Value{
"resource_group_name": tftypes.NewValue(tftypes.String, "bar"),
"name": tftypes.NewValue(tftypes.String, "papaya"),
},
)),
DisplayName: "papaya",
})
push(list.ListResult{
Resource: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
"location": tftypes.String,
"name": tftypes.String,
"resource_group_name": tftypes.String,
"instances": tftypes.Number,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "bar/birne"),
"location": tftypes.NewValue(tftypes.String, "westeurope"),
"name": tftypes.NewValue(tftypes.String, "birne"),
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"instances": tftypes.NewValue(tftypes.Number, 8564),
},
)),
Identity: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"resource_group_name": tftypes.String,
"name": tftypes.String,
},
},
map[string]tftypes.Value{
"resource_group_name": tftypes.NewValue(tftypes.String, "bar"),
"name": tftypes.NewValue(tftypes.String, "birne"),
},
)),
DisplayName: "birne",
})
push(list.ListResult{
Resource: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"id": tftypes.String,
"location": tftypes.String,
"name": tftypes.String,
"resource_group_name": tftypes.String,
"instances": tftypes.Number,
},
},
map[string]tftypes.Value{
"id": tftypes.NewValue(tftypes.String, "bar/kirsche"),
"location": tftypes.NewValue(tftypes.String, "westeurope"),
"name": tftypes.NewValue(tftypes.String, "kirsche"),
"resource_group_name": tftypes.NewValue(tftypes.String, "foo"),
"instances": tftypes.NewValue(tftypes.Number, 500),
},
)),
Identity: teststep.Pointer(tftypes.NewValue(
tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"resource_group_name": tftypes.String,
"name": tftypes.String,
},
},
map[string]tftypes.Value{
"resource_group_name": tftypes.NewValue(tftypes.String, "bar"),
"name": tftypes.NewValue(tftypes.String, "kirsche"),
},
)),
DisplayName: "kirsche",
})

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, this test is trying to demonstrate that using the pattern some_list_resource.test* will correctly compare against a sum of multiple list blocks as opposed to just resources found by the last list block in the config, which is how the check currently behaves.

total := 0
for _, summary := range req.QuerySummaries {
// To support query tests where for_each is used to construct the list blocks dynamically (e.g. with child resources) we allow
// specifying a trailing '*' to indicate that we should be looking for multiple summaries

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to use [*] instead of *? Indicating more clearly (imo) that we're looking for multiple keys for a specific list declaration

}
} else {
// when using for_each summary.Address returns as `list.{resource_name}.{resource_label}[{each.key}]`
if strings.HasPrefix(strings.TrimPrefix(summary.Address, "list."), strings.TrimSuffix(e.resourceAddress, "*")) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a case of "just don't do that", but hypothetically if someone specifies a query config like the below, this check would return an unexpected count

Configuration
list "azurerm_virtual_network" "test" {
  provider = azurerm

  include_resource = true

  config {
    resource_group_name = "acctestRG-%d"
  }
}

list "azurerm_subnet" "test" {
  for_each = toset([for vnet in list.azurerm_virtual_network.test.data : vnet.state.id])

  provider = azurerm

  config {
    virtual_network_id = each.key
  }
}

list "azurerm_subnet" "test-single" {
  provider = azurerm

  config {
    virtual_network_id = list.azurerm_virtual_network.test.data[0].state.id
  }
}
Test Step
			{
				Query:  true,
				Config: r.multipleParentsQuery(data),
				QueryResultChecks: []querycheck.QueryResultCheck{
					querycheck.ExpectLength("azurerm_virtual_network.test", 2),
					// this will return 8 due to the `test-single` summaries being included
					querycheck.ExpectLength("azurerm_subnet.test*", 5),
					querycheck.ExpectLength("azurerm_subnet.test-single", 3),
				},
			},

This could be solved by using [*] like mentioned above, and then this check becomes strings.HasPrefix(strings.TrimPrefix(summary.Address, "list."), strings.TrimSuffix(e.resourceAddress, "*]"))

(example in azurerm: resource test file & modified ExpectLength check)

@stephybun
Copy link
Member Author

Thanks for all the feedback and discussion.

Two things need to happen

  1. ExpectLength check needs to be fixed to use the provided resource address, otherwise it will produce false results in cases where there is more than one list block in the config
  2. We need to introduce a new check for handling the for_each case described here that will accept a regex as input for more controlled pattern matching

I'm going to open separate PRs for these two pieces of work then close this WIP.

@stephybun
Copy link
Member Author

Superseded by #604 and #607

@stephybun stephybun closed this Feb 11, 2026
@stephybun stephybun deleted the b/support-for-each-list-blocks branch February 19, 2026 07:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants