diff --git a/.changes/unreleased/FEATURES-20251111-151917.yaml b/.changes/unreleased/FEATURES-20251111-151917.yaml new file mode 100644 index 00000000..9a3a0f6e --- /dev/null +++ b/.changes/unreleased/FEATURES-20251111-151917.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'queryfilter: Introduces new `queryfilter` package with interface and built-in query check filtering functionality.' +time: 2025-11-11T15:19:17.237154-05:00 +custom: + Issue: "573" diff --git a/.changes/unreleased/FEATURES-20251111-152247.yaml b/.changes/unreleased/FEATURES-20251111-152247.yaml new file mode 100644 index 00000000..7b807ec7 --- /dev/null +++ b/.changes/unreleased/FEATURES-20251111-152247.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'querycheck: Adds `ExpectResourceDisplayName` query check to assert a display name value on a filtered query result.' +time: 2025-11-11T15:22:47.472876-05:00 +custom: + Issue: "573" diff --git a/helper/resource/query/query_checks.go b/helper/resource/query/query_checks.go index 8ef9a3e0..79a5b981 100644 --- a/helper/resource/query/query_checks.go +++ b/helper/resource/query/query_checks.go @@ -12,6 +12,7 @@ import ( "github.com/mitchellh/go-testing-interface" "github.com/hashicorp/terraform-plugin-testing/querycheck" + "github.com/hashicorp/terraform-plugin-testing/querycheck/queryfilter" ) func RunQueryChecks(ctx context.Context, t testing.T, query []tfjson.LogMsg, queryChecks []querycheck.QueryResultCheck) error { @@ -38,10 +39,19 @@ func RunQueryChecks(ctx context.Context, t testing.T, query []tfjson.LogMsg, que } } + var reqQueryData []tfjson.ListResourceFoundData for _, queryCheck := range queryChecks { + reqQueryData = found + if filterCheck, ok := queryCheck.(querycheck.QueryResultCheckWithFilters); ok { + filtered, err := runQueryFilters(ctx, filterCheck, reqQueryData) + if err != nil { + return err + } + reqQueryData = filtered + } resp := querycheck.CheckQueryResponse{} queryCheck.CheckQuery(ctx, querycheck.CheckQueryRequest{ - Query: found, + Query: reqQueryData, QuerySummary: &summary, }, &resp) @@ -50,3 +60,37 @@ func RunQueryChecks(ctx context.Context, t testing.T, query []tfjson.LogMsg, que return errors.Join(result...) } + +func runQueryFilters(ctx context.Context, filterCheck querycheck.QueryResultCheckWithFilters, queryResults []tfjson.ListResourceFoundData) ([]tfjson.ListResourceFoundData, error) { + filters := filterCheck.QueryFilters(ctx) + filteredResults := make([]tfjson.ListResourceFoundData, 0) + + // If there are no filters, just return the original results + if len(filters) == 0 { + return queryResults, nil + } + + for _, result := range queryResults { + keepResult := false + + for _, filter := range filters { + + resp := queryfilter.FilterQueryResponse{} + filter.Filter(ctx, queryfilter.FilterQueryRequest{QueryItem: result}, &resp) + + if resp.Include { + keepResult = true + } + + if resp.Error != nil { + return nil, resp.Error + } + } + + if keepResult { + filteredResults = append(filteredResults, result) + } + } + + return filteredResults, nil +} diff --git a/querycheck/contains_name.go b/querycheck/contains_name.go deleted file mode 100644 index 6cef5701..00000000 --- a/querycheck/contains_name.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package querycheck - -import ( - "context" - "fmt" - "strings" -) - -var _ QueryResultCheck = contains{} - -type contains struct { - resourceAddress string - check string -} - -func (c contains) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { - for _, res := range req.Query { - if strings.EqualFold(c.check, res.DisplayName) { - return - } - } - - resp.Error = fmt.Errorf("expected to find resource with display name %q in results but resource was not found", c.check) - -} - -// ContainsResourceWithName returns a query check that asserts that a resource with a given display name exists within the returned results of the query. -// -// This query check can only be used with managed resources that support query. Query is only supported in Terraform v1.14+ -func ContainsResourceWithName(resourceAddress string, displayName string) QueryResultCheck { - return contains{ - resourceAddress: resourceAddress, - check: displayName, - } -} diff --git a/querycheck/contains_name_test.go b/querycheck/contains_name_test.go deleted file mode 100644 index 8ae703b1..00000000 --- a/querycheck/contains_name_test.go +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package querycheck_test - -import ( - "regexp" - "testing" - - "github.com/hashicorp/terraform-plugin-go/tfprotov6" - r "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" - "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" - "github.com/hashicorp/terraform-plugin-testing/querycheck" - "github.com/hashicorp/terraform-plugin-testing/tfversion" -) - -func TestContainsResourceWithName(t *testing.T) { - t.Parallel() - - r.UnitTest(t, r.TestCase{ - TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_14_0), - }, - ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ - "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ - ListResources: map[string]testprovider.ListResource{ - "examplecloud_containerette": examplecloudListResource(), - }, - Resources: map[string]testprovider.Resource{ - "examplecloud_containerette": examplecloudResource(), - }, - }), - }, - Steps: []r.TestStep{ - { // config mode step 1 needs tf file with terraform providers block - // this step should provision all the resources that the query is support to list - // for simplicity we're only "provisioning" one here - Config: ` - resource "examplecloud_containerette" "primary" { - name = "banana" - resource_group_name = "foo" - location = "westeurope" - - instances = 5 - }`, - }, - { - Query: true, - Config: ` - provider "examplecloud" {} - - list "examplecloud_containerette" "test" { - provider = examplecloud - - config { - resource_group_name = "foo" - } - } - - list "examplecloud_containerette" "test2" { - provider = examplecloud - - config { - resource_group_name = "bar" - } - } - `, - QueryResultChecks: []querycheck.QueryResultCheck{ - querycheck.ContainsResourceWithName("examplecloud_containerette.test", "banane"), - querycheck.ContainsResourceWithName("examplecloud_containerette.test", "ananas"), - querycheck.ContainsResourceWithName("examplecloud_containerette.test", "kiwi"), - querycheck.ContainsResourceWithName("examplecloud_containerette.test2", "papaya"), - querycheck.ContainsResourceWithName("examplecloud_containerette.test2", "birne"), - querycheck.ContainsResourceWithName("examplecloud_containerette.test2", "kirsche"), - }, - }, - }, - }) -} - -func TestContainsResourceWithName_NotFound(t *testing.T) { - t.Parallel() - - r.UnitTest(t, r.TestCase{ - TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_14_0), - }, - ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ - "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ - ListResources: map[string]testprovider.ListResource{ - "examplecloud_containerette": examplecloudListResource(), - }, - Resources: map[string]testprovider.Resource{ - "examplecloud_containerette": examplecloudResource(), - }, - }), - }, - Steps: []r.TestStep{ - { // config mode step 1 needs tf file with terraform providers block - // this step should provision all the resources that the query is support to list - // for simplicity we're only "provisioning" one here - Config: ` - resource "examplecloud_containerette" "primary" { - name = "banana" - resource_group_name = "foo" - location = "westeurope" - - instances = 5 - }`, - }, - { - Query: true, - Config: ` - provider "examplecloud" {} - - list "examplecloud_containerette" "test" { - provider = examplecloud - - config { - resource_group_name = "foo" - } - } - - list "examplecloud_containerette" "test2" { - provider = examplecloud - - config { - resource_group_name = "bar" - } - } - `, - QueryResultChecks: []querycheck.QueryResultCheck{ - querycheck.ContainsResourceWithName("examplecloud_containerette.test", "pflaume"), - }, - ExpectError: regexp.MustCompile("expected to find resource with display name \"pflaume\" in results but resource was not found"), - }, - }, - }) -} diff --git a/querycheck/expect_identity.go b/querycheck/expect_identity.go index 2d8777f7..39439099 100644 --- a/querycheck/expect_identity.go +++ b/querycheck/expect_identity.go @@ -73,10 +73,22 @@ func (e expectIdentity) CheckQuery(_ context.Context, req CheckQueryRequest, res var errCollection []error errCollection = append(errCollection, fmt.Errorf("an identity with the following attributes was not found")) + var keys []string + + for k := range e.check { + keys = append(keys, k) + } + + sort.SliceStable(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + // wrap errors for each check - for attr, check := range e.check { + for _, attr := range keys { + check := e.check[attr] errCollection = append(errCollection, fmt.Errorf("attribute %q: %s", attr, check)) } + errCollection = append(errCollection, fmt.Errorf("address: %s\n", e.listResourceAddress)) resp.Error = errors.Join(errCollection...) } diff --git a/querycheck/expect_resource_display_name.go b/querycheck/expect_resource_display_name.go new file mode 100644 index 00000000..89f3f00c --- /dev/null +++ b/querycheck/expect_resource_display_name.go @@ -0,0 +1,70 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package querycheck + +import ( + "context" + "fmt" + "strings" + + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/querycheck/queryfilter" +) + +var _ QueryResultCheck = expectResourceDisplayName{} +var _ QueryResultCheckWithFilters = expectResourceDisplayName{} + +type expectResourceDisplayName struct { + listResourceAddress string + filter queryfilter.QueryFilter + displayName knownvalue.Check +} + +func (e expectResourceDisplayName) QueryFilters(ctx context.Context) []queryfilter.QueryFilter { + if e.filter == nil { + return []queryfilter.QueryFilter{} + } + + return []queryfilter.QueryFilter{ + e.filter, + } +} + +func (e expectResourceDisplayName) CheckQuery(_ context.Context, req CheckQueryRequest, resp *CheckQueryResponse) { + listRes := make([]tfjson.ListResourceFoundData, 0) + for _, result := range req.Query { + if strings.TrimPrefix(result.Address, "list.") == e.listResourceAddress { + listRes = append(listRes, result) + } + } + + if len(listRes) == 0 { + resp.Error = fmt.Errorf("%s - no query results found after filtering", e.listResourceAddress) + return + } + + if len(listRes) > 1 { + resp.Error = fmt.Errorf("%s - more than 1 query result found after filtering", e.listResourceAddress) + return + } + res := listRes[0] + if err := e.displayName.CheckValue(res.DisplayName); err != nil { + resp.Error = fmt.Errorf("error checking value for display name %s, err: %s", e.displayName.String(), err) + return + } + +} + +// ExpectResourceDisplayName returns a query check that asserts that a resource with a given display name exists within the returned results of the query. +// +// This query check can only be used with managed resources that support query. Query is only supported in Terraform v1.14+ +func ExpectResourceDisplayName(listResourceAddress string, filter queryfilter.QueryFilter, displayName knownvalue.Check) QueryResultCheck { + return expectResourceDisplayName{ + listResourceAddress: listResourceAddress, + filter: filter, + displayName: displayName, + } +} diff --git a/querycheck/expect_resource_display_name_test.go b/querycheck/expect_resource_display_name_test.go new file mode 100644 index 00000000..0a4d8696 --- /dev/null +++ b/querycheck/expect_resource_display_name_test.go @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MPL-2.0 + +package querycheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/querycheck" + "github.com/hashicorp/terraform-plugin-testing/querycheck/queryfilter" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestExpectResourceDisplayNameExact(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + list "examplecloud_containerette" "test2" { + provider = examplecloud + + config { + resource_group_name = "bar" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectResourceDisplayName("examplecloud_containerette.test", queryfilter.ByResourceIdentity(map[string]knownvalue.Check{ + "name": knownvalue.StringExact("ananas"), + "resource_group_name": knownvalue.StringExact("foo"), + }), knownvalue.StringExact("ananas")), + querycheck.ExpectResourceDisplayName("examplecloud_containerette.test", queryfilter.ByResourceIdentity(map[string]knownvalue.Check{ + "name": knownvalue.StringExact("banane"), + "resource_group_name": knownvalue.StringExact("foo"), + }), knownvalue.StringExact("banane")), + }, + }, + }, + }) +} + +func TestExpectResourceDisplayNameExact_TooManyResults(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectResourceDisplayName("examplecloud_containerette.test", nil, knownvalue.StringExact("ananas")), + }, + ExpectError: regexp.MustCompile("examplecloud_containerette.test - more than 1 query result found after filtering"), + }, + }, + }) +} + +func TestExpectResourceDisplayNameExact_NoResults(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectResourceDisplayName("examplecloud_containerette.test", queryfilter.ByResourceIdentity(map[string]knownvalue.Check{}), + knownvalue.StringExact("ananas")), + }, + ExpectError: regexp.MustCompile("examplecloud_containerette.test - no query results found after filtering"), + }, + }, + }) +} + +func TestExpectResourceDisplayNameExact_InvalidDisplayName(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + ListResources: map[string]testprovider.ListResource{ + "examplecloud_containerette": examplecloudListResource(), + }, + Resources: map[string]testprovider.Resource{ + "examplecloud_containerette": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { // config mode step 1 needs tf file with terraform providers block + // this step should provision all the resources that the query is support to list + // for simplicity we're only "provisioning" one here + Config: ` + resource "examplecloud_containerette" "primary" { + name = "banana" + resource_group_name = "foo" + location = "westeurope" + + instances = 5 + }`, + }, + { + Query: true, + Config: ` + provider "examplecloud" {} + + list "examplecloud_containerette" "test" { + provider = examplecloud + + config { + resource_group_name = "foo" + } + } + `, + QueryResultChecks: []querycheck.QueryResultCheck{ + querycheck.ExpectResourceDisplayName("examplecloud_containerette.test", queryfilter.ByResourceIdentity(map[string]knownvalue.Check{ + "name": knownvalue.StringExact("ananas"), + "resource_group_name": knownvalue.StringExact("foo"), + }), knownvalue.StringExact("invalid")), + }, + ExpectError: regexp.MustCompile("error checking value for display name invalid, err: expected value invalid for StringExact check, got: ananas"), + }, + }, + }) +} diff --git a/querycheck/query_check.go b/querycheck/query_check.go index 66d32628..9728f11d 100644 --- a/querycheck/query_check.go +++ b/querycheck/query_check.go @@ -7,6 +7,8 @@ import ( "context" tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/querycheck/queryfilter" ) // QueryResultCheck defines an interface for implementing test logic to apply an assertion against a collection of found @@ -16,6 +18,14 @@ type QueryResultCheck interface { CheckQuery(context.Context, CheckQueryRequest, *CheckQueryResponse) } +// QueryResultCheckWithFilters is an interface type that extends QueryResultCheck to include declarative query filters. +type QueryResultCheckWithFilters interface { + QueryResultCheck + + // QueryFilters should return a slice of queryfilter.QueryFilter that will be applied to the check. + QueryFilters(context.Context) []queryfilter.QueryFilter +} + // CheckQueryRequest is a request for an invoke of the CheckQuery function. type CheckQueryRequest struct { // Query represents the parsed log messages relating to found resources returned by the `terraform query -json` command. diff --git a/querycheck/queryfilter/filter.go b/querycheck/queryfilter/filter.go new file mode 100644 index 00000000..716ccc2a --- /dev/null +++ b/querycheck/queryfilter/filter.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package queryfilter + +import ( + "context" + + tfjson "github.com/hashicorp/terraform-json" +) + +// QueryFilter defines an interface for implementing declarative filtering logic to apply to query results before +// the results are passed to a query check request. +type QueryFilter interface { + Filter(context.Context, FilterQueryRequest, *FilterQueryResponse) +} + +// FilterQueryRequest is a request to a filter function. +type FilterQueryRequest struct { + // QueryItem represents a single parsed log message relating to a found resource returned by the `terraform query -json` command. + QueryItem tfjson.ListResourceFoundData +} + +// FilterQueryResponse is a response to a filter function. +type FilterQueryResponse struct { + // Include indicates whether the QueryItem should be included in CheckQueryRequest.Query + Include bool + + // Error is used to report the failure of filtering and is combined with other QueryFilter errors + // to be reported as a test failure. + Error error +} diff --git a/querycheck/queryfilter/filter_by_display_name.go b/querycheck/queryfilter/filter_by_display_name.go new file mode 100644 index 00000000..025559b7 --- /dev/null +++ b/querycheck/queryfilter/filter_by_display_name.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package queryfilter + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-testing/knownvalue" +) + +type filterByDisplayName struct { + displayNameCheck knownvalue.Check +} + +func (f filterByDisplayName) Filter(ctx context.Context, req FilterQueryRequest, resp *FilterQueryResponse) { + if err := f.displayNameCheck.CheckValue(req.QueryItem.DisplayName); err == nil { + resp.Include = true + return + } +} + +// ByDisplayNameExact returns a query filter that only includes query items that match +// the specified display name. +func ByDisplayName(displayNameCheck knownvalue.Check) QueryFilter { + return filterByDisplayName{ + displayNameCheck: displayNameCheck, + } +} diff --git a/querycheck/queryfilter/filter_by_display_name_test.go b/querycheck/queryfilter/filter_by_display_name_test.go new file mode 100644 index 00000000..a7ba1f29 --- /dev/null +++ b/querycheck/queryfilter/filter_by_display_name_test.go @@ -0,0 +1,99 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package queryfilter_test + +import ( + "regexp" + "testing" + + "github.com/google/go-cmp/cmp" + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/querycheck/queryfilter" +) + +func TestByDisplayName(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + displayName knownvalue.Check + queryItem tfjson.ListResourceFoundData + expectInclude bool + expectedError error + }{ + "nil-query-result-exact": { + displayName: knownvalue.StringExact("test"), + expectInclude: false, + }, + "nil-query-result-regex": { + displayName: knownvalue.StringRegexp(regexp.MustCompile("display")), + expectInclude: false, + }, + "empty-display-name-exact": { + displayName: knownvalue.StringExact(""), + expectInclude: true, + }, + "empty-display-name-regexp": { + displayName: knownvalue.StringRegexp(regexp.MustCompile("")), + expectInclude: true, + }, + "included-exact": { + displayName: knownvalue.StringExact("test"), + queryItem: tfjson.ListResourceFoundData{ + DisplayName: "test", + }, + expectInclude: true, + }, + "included-regex": { + displayName: knownvalue.StringRegexp(regexp.MustCompile("test")), + queryItem: tfjson.ListResourceFoundData{ + DisplayName: "test", + }, + expectInclude: true, + }, + "not-included-exact": { + displayName: knownvalue.StringExact("test"), + queryItem: tfjson.ListResourceFoundData{ + DisplayName: "testsss", + }, + expectInclude: false, + }, + "not-included-regex": { + displayName: knownvalue.StringRegexp(regexp.MustCompile("invalid")), + queryItem: tfjson.ListResourceFoundData{ + DisplayName: "testsss", + }, + expectInclude: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := queryfilter.FilterQueryRequest{QueryItem: testCase.queryItem} + + resp := &queryfilter.FilterQueryResponse{} + + queryfilter.ByDisplayName(testCase.displayName).Filter(t.Context(), req, resp) + + if testCase.expectInclude != resp.Include { + t.Fatalf("expected included: %t, but got %t", testCase.expectInclude, resp.Include) + } + + if testCase.expectedError == nil && resp.Error != nil { + t.Errorf("unexpected error %s", resp.Error) + } + + if testCase.expectedError != nil && resp.Error == nil { + t.Errorf("expected error but got none") + } + + if diff := cmp.Diff(resp.Error, testCase.expectedError); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/querycheck/queryfilter/filter_by_resource_identity.go b/querycheck/queryfilter/filter_by_resource_identity.go new file mode 100644 index 00000000..119da9b3 --- /dev/null +++ b/querycheck/queryfilter/filter_by_resource_identity.go @@ -0,0 +1,59 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package queryfilter + +import ( + "context" + "sort" + + "github.com/hashicorp/terraform-plugin-testing/knownvalue" +) + +type filterByResourceIdentity struct { + identity map[string]knownvalue.Check +} + +func (f filterByResourceIdentity) Filter(ctx context.Context, req FilterQueryRequest, resp *FilterQueryResponse) { + if len(req.QueryItem.Identity) != len(f.identity) { + resp.Include = false + return + } + + var keys []string + + for k := range f.identity { + keys = append(keys, k) + } + + sort.SliceStable(keys, func(i, j int) bool { + return keys[i] < keys[j] + }) + + for _, k := range keys { + actualIdentityVal, ok := req.QueryItem.Identity[k] + + if !ok { + resp.Include = false + return + } + + if err := f.identity[k].CheckValue(actualIdentityVal); err != nil { + resp.Include = false + return + } + } + + resp.Include = true +} + +// ByResourceIdentity returns a query filter that only includes query items that match +// the given resource identity. +// +// Errors thrown by the given known value checks are only used to filter out non-matching query +// items and are otherwise ignored. +func ByResourceIdentity(identity map[string]knownvalue.Check) QueryFilter { + return filterByResourceIdentity{ + identity: identity, + } +} diff --git a/querycheck/queryfilter/filter_by_resource_identity_test.go b/querycheck/queryfilter/filter_by_resource_identity_test.go new file mode 100644 index 00000000..e2f1fb46 --- /dev/null +++ b/querycheck/queryfilter/filter_by_resource_identity_test.go @@ -0,0 +1,169 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package queryfilter_test + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/querycheck/queryfilter" +) + +func TestByResourceIdentity(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + identity map[string]knownvalue.Check + queryItem tfjson.ListResourceFoundData + expectInclude bool + expectedError error + }{ + "nil-query-result": { + identity: map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + }, + expectInclude: false, + }, + "nil-identity": { + expectInclude: true, + }, + "included": { + identity: map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + "list_of_numbers": knownvalue.ListExact( + []knownvalue.Check{ + knownvalue.Int64Exact(1), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(3), + knownvalue.Int64Exact(4), + }, + ), + }, + queryItem: tfjson.ListResourceFoundData{ + DisplayName: "test", + Identity: map[string]any{ + "id": "id-123", + "list_of_numbers": []any{ + json.Number("1"), + json.Number("2"), + json.Number("3"), + json.Number("4"), + }, + }, + }, + expectInclude: true, + }, + "not-included-nonexistent-attribute": { + identity: map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + "nonexistent_attr": knownvalue.StringExact("hello"), + "list_of_numbers": knownvalue.ListExact( + []knownvalue.Check{ + knownvalue.Int64Exact(1), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(3), + knownvalue.Int64Exact(4), + }, + ), + }, + queryItem: tfjson.ListResourceFoundData{ + DisplayName: "test", + Identity: map[string]any{ + "id": "id-123", + "list_of_numbers": []any{ + json.Number("1"), + json.Number("2"), + json.Number("3"), + json.Number("4"), + }, + }, + }, + expectInclude: false, + }, + "not-included-incorrect-string": { + identity: map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + "list_of_numbers": knownvalue.ListExact( + []knownvalue.Check{ + knownvalue.Int64Exact(1), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(3), + knownvalue.Int64Exact(4), + }, + ), + }, + queryItem: tfjson.ListResourceFoundData{ + DisplayName: "test", + Identity: map[string]any{ + "id": "incorrect", + "list_of_numbers": []any{ + json.Number("1"), + json.Number("2"), + json.Number("3"), + json.Number("4"), + }, + }, + }, + expectInclude: false, + }, + "not-included-incorrect-list-item": { + identity: map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + "list_of_numbers": knownvalue.ListExact( + []knownvalue.Check{ + knownvalue.Int64Exact(1), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(3), + knownvalue.Int64Exact(4), + }, + ), + }, + queryItem: tfjson.ListResourceFoundData{ + DisplayName: "test", + Identity: map[string]any{ + "id": "id-123", + "list_of_numbers": []any{ + json.Number("1"), + json.Number("2"), + json.Number("333"), + json.Number("4"), + }, + }, + }, + expectInclude: false, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := queryfilter.FilterQueryRequest{QueryItem: testCase.queryItem} + + resp := &queryfilter.FilterQueryResponse{} + + queryfilter.ByResourceIdentity(testCase.identity).Filter(t.Context(), req, resp) + + if testCase.expectInclude != resp.Include { + t.Fatalf("expected included: %t, but got %t", testCase.expectInclude, resp.Include) + } + + if testCase.expectedError == nil && resp.Error != nil { + t.Errorf("unexpected error %s", resp.Error) + } + + if testCase.expectedError != nil && resp.Error == nil { + t.Errorf("expected error but got none") + } + + if diff := cmp.Diff(resp.Error, testCase.expectedError); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +}