diff --git a/.changelog/396.txt b/.changelog/396.txt new file mode 100644 index 000000000..a76abcac2 --- /dev/null +++ b/.changelog/396.txt @@ -0,0 +1,11 @@ +```release-note:feature +path: Introduced attribute path expressions +``` + +```release-note:enhancement +tfsdk: Added `AttributePathExpression` field to `ModifyAttributePlanRequest` and `ValidateAttributeRequest` types +``` + +```release-note:enhancement +tfsdk: Added `PathMatches` method to `Config`, `Plan`, and `State` types +``` diff --git a/internal/fromtftypes/attribute_path_step.go b/internal/fromtftypes/attribute_path_step.go new file mode 100644 index 000000000..aef594bd4 --- /dev/null +++ b/internal/fromtftypes/attribute_path_step.go @@ -0,0 +1,35 @@ +package fromtftypes + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// AttributePathStep returns the path.PathStep equivalent of a +// tftypes.AttributePathStep. An error is returned instead of diag.Diagnostics +// so callers can include appropriate logical context about when the error +// occurred. +func AttributePathStep(ctx context.Context, tfType tftypes.AttributePathStep, attrType attr.Type) (path.PathStep, error) { + switch tfType := tfType.(type) { + case tftypes.AttributeName: + return path.PathStepAttributeName(string(tfType)), nil + case tftypes.ElementKeyInt: + return path.PathStepElementKeyInt(int64(tfType)), nil + case tftypes.ElementKeyString: + return path.PathStepElementKeyString(string(tfType)), nil + case tftypes.ElementKeyValue: + attrValue, err := Value(ctx, tftypes.Value(tfType), attrType) + + if err != nil { + return nil, fmt.Errorf("unable to create PathStepElementKeyValue from tftypes.Value: %w", err) + } + + return path.PathStepElementKeyValue{Value: attrValue}, nil + default: + return nil, fmt.Errorf("unknown tftypes.AttributePathStep: %#v", tfType) + } +} diff --git a/internal/fromtftypes/attribute_path_step_test.go b/internal/fromtftypes/attribute_path_step_test.go new file mode 100644 index 000000000..39b5494be --- /dev/null +++ b/internal/fromtftypes/attribute_path_step_test.go @@ -0,0 +1,83 @@ +package fromtftypes_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fromtftypes" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestAttributePathStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + tfType tftypes.AttributePathStep + attrType attr.Type + expected path.PathStep + expectedError error + }{ + "nil": { + tfType: nil, + expected: nil, + expectedError: fmt.Errorf("unknown tftypes.AttributePathStep: "), + }, + "PathStepAttributeName": { + tfType: tftypes.AttributeName("test"), + expected: path.PathStepAttributeName("test"), + }, + "PathStepElementKeyInt": { + tfType: tftypes.ElementKeyInt(1), + expected: path.PathStepElementKeyInt(1), + }, + "PathStepElementKeyString": { + tfType: tftypes.ElementKeyString("test"), + expected: path.PathStepElementKeyString("test"), + }, + "PathStepElementKeyValue": { + tfType: tftypes.ElementKeyValue(tftypes.NewValue(tftypes.String, "test")), + attrType: types.StringType, + expected: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + }, + "PathStepElementKeyValue-error": { + tfType: tftypes.ElementKeyValue(tftypes.NewValue(tftypes.String, "test")), + attrType: types.BoolType, + expected: nil, + expectedError: fmt.Errorf("unable to create PathStepElementKeyValue from tftypes.Value: unable to convert tftypes.Value (tftypes.String<\"test\">) to attr.Value: can't unmarshal tftypes.String into *bool, expected boolean"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := fromtftypes.AttributePathStep(context.Background(), testCase.tfType, testCase.attrType) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("expected no error, got: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedError, err) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, tfType: %s", testCase.expectedError) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/fromtftypes/doc.go b/internal/fromtftypes/doc.go new file mode 100644 index 000000000..c97dd1149 --- /dev/null +++ b/internal/fromtftypes/doc.go @@ -0,0 +1,3 @@ +// Package fromtftypes contains functions to convert from terraform-plugin-go +// tftypes types to framework types. +package fromtftypes diff --git a/internal/fromtftypes/value.go b/internal/fromtftypes/value.go new file mode 100644 index 000000000..4fc20875d --- /dev/null +++ b/internal/fromtftypes/value.go @@ -0,0 +1,24 @@ +package fromtftypes + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// Value returns the attr.Value equivalent to the tftypes.Value. +func Value(ctx context.Context, tfType tftypes.Value, attrType attr.Type) (attr.Value, error) { + if attrType == nil { + return nil, fmt.Errorf("unable to convert tftypes.Value (%s) to attr.Value: missing attr.Type", tfType.String()) + } + + attrValue, err := attrType.ValueFromTerraform(ctx, tfType) + + if err != nil { + return nil, fmt.Errorf("unable to convert tftypes.Value (%s) to attr.Value: %w", tfType.String(), err) + } + + return attrValue, nil +} diff --git a/internal/fromtftypes/value_test.go b/internal/fromtftypes/value_test.go new file mode 100644 index 000000000..2ea227f76 --- /dev/null +++ b/internal/fromtftypes/value_test.go @@ -0,0 +1,313 @@ +package fromtftypes_test + +import ( + "context" + "fmt" + "math/big" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fromtftypes" + testtypes "github.com/hashicorp/terraform-plugin-framework/internal/testing/types" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestValue(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + tfType tftypes.Value + attrType attr.Type + expected attr.Value + expectedError error + }{ + "empty-tftype": { + tfType: tftypes.Value{}, + attrType: types.BoolType, + expected: types.Bool{Null: true}, + }, + "nil-attr-type": { + tfType: tftypes.Value{}, + attrType: nil, + expected: nil, + expectedError: fmt.Errorf("unable to convert tftypes.Value (invalid typeless tftypes.Value<>) to attr.Value: missing attr.Type"), + }, + "invalid-attr-type": { + tfType: tftypes.NewValue(tftypes.Bool, true), + attrType: testtypes.InvalidType{}, + expected: nil, + expectedError: fmt.Errorf("unable to convert tftypes.Value (tftypes.Bool<\"true\">) to attr.Value: intentional ValueFromTerraform error"), + }, + "bool-null": { + tfType: tftypes.NewValue(tftypes.Bool, nil), + attrType: types.BoolType, + expected: types.Bool{Null: true}, + }, + "bool-unknown": { + tfType: tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), + attrType: types.BoolType, + expected: types.Bool{Unknown: true}, + }, + "bool-value": { + tfType: tftypes.NewValue(tftypes.Bool, true), + attrType: types.BoolType, + expected: types.Bool{Value: true}, + }, + "float64-null": { + tfType: tftypes.NewValue(tftypes.Number, nil), + attrType: types.Float64Type, + expected: types.Float64{Null: true}, + }, + "float64-unknown": { + tfType: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + attrType: types.Float64Type, + expected: types.Float64{Unknown: true}, + }, + "float64-value": { + tfType: tftypes.NewValue(tftypes.Number, big.NewFloat(1.2)), + attrType: types.Float64Type, + expected: types.Float64{Value: 1.2}, + }, + "int64-null": { + tfType: tftypes.NewValue(tftypes.Number, nil), + attrType: types.Int64Type, + expected: types.Int64{Null: true}, + }, + "int64-unknown": { + tfType: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + attrType: types.Int64Type, + expected: types.Int64{Unknown: true}, + }, + "int64-value": { + tfType: tftypes.NewValue(tftypes.Number, 123), + attrType: types.Int64Type, + expected: types.Int64{Value: 123}, + }, + "list-null": { + tfType: tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + nil, + ), + attrType: types.ListType{ + ElemType: types.StringType, + }, + expected: types.List{ + ElemType: types.StringType, + Null: true, + }, + }, + "list-unknown": { + tfType: tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + tftypes.UnknownValue, + ), + attrType: types.ListType{ + ElemType: types.StringType, + }, + expected: types.List{ + ElemType: types.StringType, + Unknown: true, + }, + }, + "list-value": { + tfType: tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + attrType: types.ListType{ + ElemType: types.StringType, + }, + expected: types.List{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "test-value"}, + }, + }, + }, + "number-null": { + tfType: tftypes.NewValue(tftypes.Number, nil), + attrType: types.NumberType, + expected: types.Number{Null: true}, + }, + "number-unknown": { + tfType: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), + attrType: types.NumberType, + expected: types.Number{Unknown: true}, + }, + "number-value": { + tfType: tftypes.NewValue(tftypes.Number, big.NewFloat(1.2)), + attrType: types.NumberType, + expected: types.Number{Value: big.NewFloat(1.2)}, + }, + "object-null": { + tfType: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attr": tftypes.String, + }, + }, + nil, + ), + attrType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.StringType, + }, + }, + expected: types.Object{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.StringType, + }, + Null: true, + }, + }, + "object-unknown": { + tfType: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attr": tftypes.String, + }, + }, + tftypes.UnknownValue, + ), + attrType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.StringType, + }, + }, + expected: types.Object{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.StringType, + }, + Unknown: true, + }, + }, + "object-value": { + tfType: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_attr": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + attrType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.StringType, + }, + }, + expected: types.Object{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.StringType, + }, + Attrs: map[string]attr.Value{ + "test_attr": types.String{Value: "test-value"}, + }, + }, + }, + "set-null": { + tfType: tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + nil, + ), + attrType: types.SetType{ + ElemType: types.StringType, + }, + expected: types.Set{ + ElemType: types.StringType, + Null: true, + }, + }, + "set-unknown": { + tfType: tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + tftypes.UnknownValue, + ), + attrType: types.SetType{ + ElemType: types.StringType, + }, + expected: types.Set{ + ElemType: types.StringType, + Unknown: true, + }, + }, + "set-value": { + tfType: tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + attrType: types.SetType{ + ElemType: types.StringType, + }, + expected: types.Set{ + ElemType: types.StringType, + Elems: []attr.Value{ + types.String{Value: "test-value"}, + }, + }, + }, + "string-null": { + tfType: tftypes.NewValue(tftypes.String, nil), + attrType: types.StringType, + expected: types.String{Null: true}, + }, + "string-unknown": { + tfType: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + attrType: types.StringType, + expected: types.String{Unknown: true}, + }, + "string-value": { + tfType: tftypes.NewValue(tftypes.String, "test-value"), + attrType: types.StringType, + expected: types.String{Value: "test-value"}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := fromtftypes.Value(context.Background(), testCase.tfType, testCase.attrType) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("expected no error, got: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedError, err) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, tfType: %s", testCase.expectedError) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/fwserver/attribute_validation.go b/internal/fwserver/attribute_validation.go index 91aa8f739..0d3ebb126 100644 --- a/internal/fwserver/attribute_validation.go +++ b/internal/fwserver/attribute_validation.go @@ -157,8 +157,9 @@ func AttributeValidateNestedAttributes(ctx context.Context, a tfsdk.Attribute, r for idx := range l.Elems { for nestedName, nestedAttr := range a.Attributes.GetAttributes() { nestedAttrReq := tfsdk.ValidateAttributeRequest{ - AttributePath: req.AttributePath.AtListIndex(idx).AtName(nestedName), - Config: req.Config, + AttributePath: req.AttributePath.AtListIndex(idx).AtName(nestedName), + AttributePathExpression: req.AttributePathExpression.AtListIndex(idx).AtName(nestedName), + Config: req.Config, } nestedAttrResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, @@ -186,8 +187,9 @@ func AttributeValidateNestedAttributes(ctx context.Context, a tfsdk.Attribute, r for _, value := range s.Elems { for nestedName, nestedAttr := range a.Attributes.GetAttributes() { nestedAttrReq := tfsdk.ValidateAttributeRequest{ - AttributePath: req.AttributePath.AtSetValue(value).AtName(nestedName), - Config: req.Config, + AttributePath: req.AttributePath.AtSetValue(value).AtName(nestedName), + AttributePathExpression: req.AttributePathExpression.AtSetValue(value).AtName(nestedName), + Config: req.Config, } nestedAttrResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, @@ -215,8 +217,9 @@ func AttributeValidateNestedAttributes(ctx context.Context, a tfsdk.Attribute, r for key := range m.Elems { for nestedName, nestedAttr := range a.Attributes.GetAttributes() { nestedAttrReq := tfsdk.ValidateAttributeRequest{ - AttributePath: req.AttributePath.AtMapKey(key).AtName(nestedName), - Config: req.Config, + AttributePath: req.AttributePath.AtMapKey(key).AtName(nestedName), + AttributePathExpression: req.AttributePathExpression.AtMapKey(key).AtName(nestedName), + Config: req.Config, } nestedAttrResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, @@ -244,8 +247,9 @@ func AttributeValidateNestedAttributes(ctx context.Context, a tfsdk.Attribute, r if !o.Null && !o.Unknown { for nestedName, nestedAttr := range a.Attributes.GetAttributes() { nestedAttrReq := tfsdk.ValidateAttributeRequest{ - AttributePath: req.AttributePath.AtName(nestedName), - Config: req.Config, + AttributePath: req.AttributePath.AtName(nestedName), + AttributePathExpression: req.AttributePathExpression.AtName(nestedName), + Config: req.Config, } nestedAttrResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, diff --git a/internal/fwserver/block_validation.go b/internal/fwserver/block_validation.go index 7daa2526e..d504f2663 100644 --- a/internal/fwserver/block_validation.go +++ b/internal/fwserver/block_validation.go @@ -47,8 +47,9 @@ func BlockValidate(ctx context.Context, b tfsdk.Block, req tfsdk.ValidateAttribu for idx := range l.Elems { for name, attr := range b.Attributes { nestedAttrReq := tfsdk.ValidateAttributeRequest{ - AttributePath: req.AttributePath.AtListIndex(idx).AtName(name), - Config: req.Config, + AttributePath: req.AttributePath.AtListIndex(idx).AtName(name), + AttributePathExpression: req.AttributePathExpression.AtListIndex(idx).AtName(name), + Config: req.Config, } nestedAttrResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, @@ -61,8 +62,9 @@ func BlockValidate(ctx context.Context, b tfsdk.Block, req tfsdk.ValidateAttribu for name, block := range b.Blocks { nestedAttrReq := tfsdk.ValidateAttributeRequest{ - AttributePath: req.AttributePath.AtListIndex(idx).AtName(name), - Config: req.Config, + AttributePath: req.AttributePath.AtListIndex(idx).AtName(name), + AttributePathExpression: req.AttributePathExpression.AtListIndex(idx).AtName(name), + Config: req.Config, } nestedAttrResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, @@ -90,8 +92,9 @@ func BlockValidate(ctx context.Context, b tfsdk.Block, req tfsdk.ValidateAttribu for _, value := range s.Elems { for name, attr := range b.Attributes { nestedAttrReq := tfsdk.ValidateAttributeRequest{ - AttributePath: req.AttributePath.AtSetValue(value).AtName(name), - Config: req.Config, + AttributePath: req.AttributePath.AtSetValue(value).AtName(name), + AttributePathExpression: req.AttributePathExpression.AtSetValue(value).AtName(name), + Config: req.Config, } nestedAttrResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, @@ -104,8 +107,9 @@ func BlockValidate(ctx context.Context, b tfsdk.Block, req tfsdk.ValidateAttribu for name, block := range b.Blocks { nestedAttrReq := tfsdk.ValidateAttributeRequest{ - AttributePath: req.AttributePath.AtSetValue(value).AtName(name), - Config: req.Config, + AttributePath: req.AttributePath.AtSetValue(value).AtName(name), + AttributePathExpression: req.AttributePathExpression.AtSetValue(value).AtName(name), + Config: req.Config, } nestedAttrResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, diff --git a/internal/fwserver/schema_validation.go b/internal/fwserver/schema_validation.go index f46f119b4..adc581687 100644 --- a/internal/fwserver/schema_validation.go +++ b/internal/fwserver/schema_validation.go @@ -36,8 +36,9 @@ func SchemaValidate(ctx context.Context, s tfsdk.Schema, req ValidateSchemaReque for name, attribute := range s.Attributes { attributeReq := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root(name), - Config: req.Config, + AttributePath: path.Root(name), + AttributePathExpression: path.MatchRoot(name), + Config: req.Config, } attributeResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, @@ -50,8 +51,9 @@ func SchemaValidate(ctx context.Context, s tfsdk.Schema, req ValidateSchemaReque for name, block := range s.Blocks { attributeReq := tfsdk.ValidateAttributeRequest{ - AttributePath: path.Root(name), - Config: req.Config, + AttributePath: path.Root(name), + AttributePathExpression: path.MatchRoot(name), + Config: req.Config, } attributeResp := &tfsdk.ValidateAttributeResponse{ Diagnostics: resp.Diagnostics, diff --git a/path/expression.go b/path/expression.go new file mode 100644 index 000000000..b5af162ea --- /dev/null +++ b/path/expression.go @@ -0,0 +1,206 @@ +package path + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" +) + +// Expression represents an attribute path with expression steps, which can +// represent zero, one, or more actual Paths. +type Expression struct { + // root stores whether an expression was intentionally created to start + // from the root of the data. This is used with Merge to overwrite steps + // instead of appending steps. + root bool + + // steps is the transversals included with the expression. In general, + // operations against the path should protect against modification of the + // original. + steps ExpressionSteps +} + +// AtAnyListIndex returns a copied expression with a new list index step at the +// end. The returned path is safe to modify without affecting the original. +func (e Expression) AtAnyListIndex() Expression { + copiedPath := e.Copy() + + copiedPath.steps.Append(ExpressionStepElementKeyIntAny{}) + + return copiedPath +} + +// AtAnyMapKey returns a copied expression with a new map key step at the end. +// The returned path is safe to modify without affecting the original. +func (e Expression) AtAnyMapKey() Expression { + copiedPath := e.Copy() + + copiedPath.steps.Append(ExpressionStepElementKeyStringAny{}) + + return copiedPath +} + +// AtAnySetValue returns a copied expression with a new set value step at the +// end. The returned path is safe to modify without affecting the original. +func (e Expression) AtAnySetValue() Expression { + copiedPath := e.Copy() + + copiedPath.steps.Append(ExpressionStepElementKeyValueAny{}) + + return copiedPath +} + +// AtListIndex returns a copied expression with a new list index step at the +// end. The returned path is safe to modify without affecting the original. +func (e Expression) AtListIndex(index int) Expression { + copiedPath := e.Copy() + + copiedPath.steps.Append(ExpressionStepElementKeyIntExact(index)) + + return copiedPath +} + +// AtMapKey returns a copied expression with a new map key step at the end. +// The returned path is safe to modify without affecting the original. +func (e Expression) AtMapKey(key string) Expression { + copiedPath := e.Copy() + + copiedPath.steps.Append(ExpressionStepElementKeyStringExact(key)) + + return copiedPath +} + +// AtName returns a copied expression with a new attribute or block name step +// at the end. The returned path is safe to modify without affecting the +// original. +func (e Expression) AtName(name string) Expression { + copiedPath := e.Copy() + + copiedPath.steps.Append(ExpressionStepAttributeNameExact(name)) + + return copiedPath +} + +// AtParent returns a copied expression with a new parent step at the end. +// The returned path is safe to modify without affecting the original. +func (e Expression) AtParent() Expression { + copiedPath := e.Copy() + + copiedPath.steps.Append(ExpressionStepParent{}) + + return copiedPath +} + +// AtSetValue returns a copied expression with a new set value step at the end. +// The returned path is safe to modify without affecting the original. +func (e Expression) AtSetValue(value attr.Value) Expression { + copiedPath := e.Copy() + + copiedPath.steps.Append(ExpressionStepElementKeyValueExact{Value: value}) + + return copiedPath +} + +// Copy returns a duplicate of the expression that is safe to modify without +// affecting the original. +func (e Expression) Copy() Expression { + return Expression{ + steps: e.Steps().Copy(), + } +} + +// Equal returns true if the given expression is exactly equivalent. +func (e Expression) Equal(o Expression) bool { + if e.steps == nil && o.steps == nil { + return true + } + + if e.steps == nil { + return false + } + + if !e.steps.Equal(o.steps) { + return false + } + + return true +} + +// Matches returns true if the given Path is valid for the Expression. Any +// relative expression steps, such as ExpressionStepParent, are automatically +// resolved before matching. +func (e Expression) Matches(path Path) bool { + return e.steps.Matches(path.Steps()) +} + +// Merge returns a copied expression either with the steps of the given +// expression added to the end of the existing steps, or overwriting the +// steps if the given expression was a root expression. +// +// Any merged expressions will preserve all expressions steps, such as +// ExpressionStepParent, for troubleshooting. Methods such as Matches() will +// automatically resolve the expression when using it. Call the Resolve() +// method explicitly if a resolved expression without any ExpressionStepParent +// is desired. +func (e Expression) Merge(other Expression) Expression { + if other.root { + return other.Copy() + } + + copiedExpression := e.Copy() + + copiedExpression.steps.Append(other.steps...) + + return copiedExpression +} + +// Resolve returns a copied expression with any relative steps, such as +// ExpressionStepParent, resolved. This is not necessary before calling methods +// such as Matches(), however it can be useful before returning the String() +// method so the path information is simplified. +// +// Returns an empty expression if any ExpressionStepParent attempt to go +// beyond the first element. +func (e Expression) Resolve() Expression { + copiedExpression := e.Copy() + + copiedExpression.steps = copiedExpression.steps.Resolve() + + return copiedExpression +} + +// Steps returns a copy of the underlying expression steps. Returns an empty +// collection of steps if expression is nil. +func (e Expression) Steps() ExpressionSteps { + if len(e.steps) == 0 { + return ExpressionSteps{} + } + + return e.steps.Copy() +} + +// String returns the human-readable representation of the path. +// It is intended for logging and error messages and is not protected by +// compatibility guarantees. +func (e Expression) String() string { + return e.steps.String() +} + +// MatchRelative creates an empty attribute path expression that is intended +// to be combined with an existing attribute path expression. This allows +// creating a relative expression in nested schemas, using AtParent() to +// traverse up the path or other At methods to traverse further down. +func MatchRelative() Expression { + return Expression{ + steps: ExpressionSteps{}, + } +} + +// MatchRoot creates an attribute path expression starting with +// ExpressionStepAttributeNameExact. +func MatchRoot(rootAttributeName string) Expression { + return Expression{ + root: true, + steps: ExpressionSteps{ + ExpressionStepAttributeNameExact(rootAttributeName), + }, + } +} diff --git a/path/expression_step.go b/path/expression_step.go new file mode 100644 index 000000000..7e2b676db --- /dev/null +++ b/path/expression_step.go @@ -0,0 +1,20 @@ +package path + +// ExpressionStep represents an expression of an attribute path step, which may +// match zero, one, or more actual paths. +type ExpressionStep interface { + // Equal should return true if the given Step is exactly equivalent. + Equal(ExpressionStep) bool + + // Matches should return true if the given PathStep can be fulfilled by the + // ExpressionStep. + Matches(PathStep) bool + + // String should return a human-readable representation of the step + // intended for logging and error messages. There should not be usage + // that needs to be protected by compatibility guarantees. + String() string + + // unexported prevents outside types from satisfying the interface. + unexported() +} diff --git a/path/expression_step_attribute_name_exact.go b/path/expression_step_attribute_name_exact.go new file mode 100644 index 000000000..fa70fc549 --- /dev/null +++ b/path/expression_step_attribute_name_exact.go @@ -0,0 +1,43 @@ +package path + +// Ensure ExpressionStepAttributeNameExact satisfies the ExpressionStep +// interface. +var _ ExpressionStep = ExpressionStepAttributeNameExact("") + +// ExpressionStepAttributeNameExact is an attribute path expression for an +// exact attribute name match within an object. +type ExpressionStepAttributeNameExact string + +// Equal returns true if the given ExpressionStep is a +// ExpressionStepAttributeNameExact and the attribute name is equivalent. +func (s ExpressionStepAttributeNameExact) Equal(o ExpressionStep) bool { + other, ok := o.(ExpressionStepAttributeNameExact) + + if !ok { + return false + } + + return string(s) == string(other) +} + +// Matches returns true if the given PathStep is fulfilled by the +// ExpressionStepAttributeNameExact condition. +func (s ExpressionStepAttributeNameExact) Matches(pathStep PathStep) bool { + pathStepAttributeName, ok := pathStep.(PathStepAttributeName) + + if !ok { + return false + } + + return string(s) == string(pathStepAttributeName) +} + +// String returns the human-readable representation of the attribute name +// expression. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +func (s ExpressionStepAttributeNameExact) String() string { + return string(s) +} + +// unexported satisfies the Step interface. +func (s ExpressionStepAttributeNameExact) unexported() {} diff --git a/path/expression_step_attribute_name_exact_test.go b/path/expression_step_attribute_name_exact_test.go new file mode 100644 index 000000000..140ba377d --- /dev/null +++ b/path/expression_step_attribute_name_exact_test.go @@ -0,0 +1,137 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepAttributeNameExactEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepAttributeNameExact + other path.ExpressionStep + expected bool + }{ + "ExpressionStepAttributeNameExact-different": { + step: path.ExpressionStepAttributeNameExact("test"), + other: path.ExpressionStepAttributeNameExact("not-test"), + expected: false, + }, + "ExpressionStepAttributeNameExact-equal": { + step: path.ExpressionStepAttributeNameExact("test"), + other: path.ExpressionStepAttributeNameExact("test"), + expected: true, + }, + "ExpressionStepElementKeyIntExact": { + step: path.ExpressionStepAttributeNameExact("test"), + other: path.ExpressionStepElementKeyIntExact(0), + expected: false, + }, + "ExpressionStepElementKeyStringExact": { + step: path.ExpressionStepAttributeNameExact("test"), + other: path.ExpressionStepElementKeyStringExact("test"), + expected: false, + }, + "ExpressionStepElementKeyValueExact": { + step: path.ExpressionStepAttributeNameExact("test"), + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepAttributeNameExactMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepAttributeNameExact + pathStep path.PathStep + expected bool + }{ + "StepAttributeName-different": { + step: path.ExpressionStepAttributeNameExact("test"), + pathStep: path.PathStepAttributeName("not-test"), + expected: false, + }, + "StepAttributeName-equal": { + step: path.ExpressionStepAttributeNameExact("test"), + pathStep: path.PathStepAttributeName("test"), + expected: true, + }, + "StepElementKeyInt": { + step: path.ExpressionStepAttributeNameExact("test"), + pathStep: path.PathStepElementKeyInt(0), + expected: false, + }, + "StepElementKeyString": { + step: path.ExpressionStepAttributeNameExact("test"), + pathStep: path.PathStepElementKeyString("test"), + expected: false, + }, + "StepElementKeyValue": { + step: path.ExpressionStepAttributeNameExact("test"), + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Matches(testCase.pathStep) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepAttributeNameExactString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepAttributeNameExact + expected string + }{ + "basic": { + step: path.ExpressionStepAttributeNameExact("test"), + expected: "test", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_step_element_key_int_any.go b/path/expression_step_element_key_int_any.go new file mode 100644 index 000000000..a76704c0b --- /dev/null +++ b/path/expression_step_element_key_int_any.go @@ -0,0 +1,35 @@ +package path + +// Ensure ExpressionStepElementKeyIntAny satisfies the ExpressionStep +// interface. +var _ ExpressionStep = ExpressionStepElementKeyIntAny{} + +// ExpressionStepElementKeyIntAny is an attribute path expression for a matching any +// integer element key within a list. +type ExpressionStepElementKeyIntAny struct{} + +// Equal returns true if the given ExpressionStep is a +// ExpressionStepElementKeyIntAny. +func (s ExpressionStepElementKeyIntAny) Equal(o ExpressionStep) bool { + _, ok := o.(ExpressionStepElementKeyIntAny) + + return ok +} + +// Matches returns true if the given PathStep is fulfilled by the +// ExpressionStepElementKeyIntAny condition. +func (s ExpressionStepElementKeyIntAny) Matches(pathStep PathStep) bool { + _, ok := pathStep.(PathStepElementKeyInt) + + return ok +} + +// String returns the human-readable representation of the element key +// expression. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +func (s ExpressionStepElementKeyIntAny) String() string { + return "[*]" +} + +// unexported satisfies the Step interface. +func (s ExpressionStepElementKeyIntAny) unexported() {} diff --git a/path/expression_step_element_key_int_any_test.go b/path/expression_step_element_key_int_any_test.go new file mode 100644 index 000000000..f3c4759ad --- /dev/null +++ b/path/expression_step_element_key_int_any_test.go @@ -0,0 +1,132 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepElementKeyIntAnyEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyIntAny + other path.ExpressionStep + expected bool + }{ + "ExpressionStepAttributeNameExact": { + step: path.ExpressionStepElementKeyIntAny{}, + other: path.ExpressionStepAttributeNameExact("test"), + expected: false, + }, + "ExpressionStepElementKeyIntAny": { + step: path.ExpressionStepElementKeyIntAny{}, + other: path.ExpressionStepElementKeyIntAny{}, + expected: true, + }, + "ExpressionStepElementKeyIntExact": { + step: path.ExpressionStepElementKeyIntAny{}, + other: path.ExpressionStepElementKeyIntExact(0), + expected: false, + }, + "ExpressionStepElementKeyStringExact": { + step: path.ExpressionStepElementKeyIntAny{}, + other: path.ExpressionStepElementKeyStringExact("test"), + expected: false, + }, + "ExpressionStepElementKeyValueExact": { + step: path.ExpressionStepElementKeyIntAny{}, + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyIntAnyMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyIntAny + pathStep path.PathStep + expected bool + }{ + "StepAttributeName": { + step: path.ExpressionStepElementKeyIntAny{}, + pathStep: path.PathStepAttributeName("test"), + expected: false, + }, + "StepElementKeyInt": { + step: path.ExpressionStepElementKeyIntAny{}, + pathStep: path.PathStepElementKeyInt(0), + expected: true, + }, + "StepElementKeyString": { + step: path.ExpressionStepElementKeyIntAny{}, + pathStep: path.PathStepElementKeyString("test"), + expected: false, + }, + "StepElementKeyValue": { + step: path.ExpressionStepElementKeyIntAny{}, + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Matches(testCase.pathStep) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyIntAnyString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyIntAny + expected string + }{ + "basic": { + step: path.ExpressionStepElementKeyIntAny{}, + expected: "[*]", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_step_element_key_int_exact.go b/path/expression_step_element_key_int_exact.go new file mode 100644 index 000000000..874eec37c --- /dev/null +++ b/path/expression_step_element_key_int_exact.go @@ -0,0 +1,47 @@ +package path + +import ( + "fmt" +) + +// Ensure ExpressionStepElementKeyIntExact satisfies the ExpressionStep +// interface. +var _ ExpressionStep = ExpressionStepElementKeyIntExact(0) + +// ExpressionStepElementKeyIntExact is an attribute path expression for an exact integer +// element key match within a list. List indexing starts at 0. +type ExpressionStepElementKeyIntExact int64 + +// Equal returns true if the given ExpressionStep is a +// ExpressionStepElementKeyIntExact and the integer element key is equivalent. +func (s ExpressionStepElementKeyIntExact) Equal(o ExpressionStep) bool { + other, ok := o.(ExpressionStepElementKeyIntExact) + + if !ok { + return false + } + + return int64(s) == int64(other) +} + +// Matches returns true if the given PathStep is fulfilled by the +// ExpressionStepElementKeyIntExact condition. +func (s ExpressionStepElementKeyIntExact) Matches(pathStep PathStep) bool { + pathStepElementKeyInt, ok := pathStep.(PathStepElementKeyInt) + + if !ok { + return false + } + + return int64(s) == int64(pathStepElementKeyInt) +} + +// String returns the human-readable representation of the element key +// expression. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +func (s ExpressionStepElementKeyIntExact) String() string { + return fmt.Sprintf("[%d]", s) +} + +// unexported satisfies the Step interface. +func (s ExpressionStepElementKeyIntExact) unexported() {} diff --git a/path/expression_step_element_key_int_exact_test.go b/path/expression_step_element_key_int_exact_test.go new file mode 100644 index 000000000..9ef627b15 --- /dev/null +++ b/path/expression_step_element_key_int_exact_test.go @@ -0,0 +1,142 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepElementKeyIntExactEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyIntExact + other path.ExpressionStep + expected bool + }{ + "ExpressionStepAttributeNameExact": { + step: path.ExpressionStepElementKeyIntExact(0), + other: path.ExpressionStepAttributeNameExact("test"), + expected: false, + }, + "ExpressionStepElementKeyIntAny": { + step: path.ExpressionStepElementKeyIntExact(0), + other: path.ExpressionStepElementKeyIntAny{}, + expected: false, + }, + "ExpressionStepElementKeyIntExact-different": { + step: path.ExpressionStepElementKeyIntExact(0), + other: path.ExpressionStepElementKeyIntExact(1), + expected: false, + }, + "ExpressionStepElementKeyIntExact-equal": { + step: path.ExpressionStepElementKeyIntExact(0), + other: path.ExpressionStepElementKeyIntExact(0), + expected: true, + }, + "ExpressionStepElementKeyStringExact": { + step: path.ExpressionStepElementKeyIntExact(0), + other: path.ExpressionStepElementKeyStringExact("test"), + expected: false, + }, + "ExpressionStepElementKeyValueExact": { + step: path.ExpressionStepElementKeyIntExact(0), + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyIntExactMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyIntExact + pathStep path.PathStep + expected bool + }{ + "StepAttributeName": { + step: path.ExpressionStepElementKeyIntExact(0), + pathStep: path.PathStepAttributeName("test"), + expected: false, + }, + "StepElementKeyInt-different": { + step: path.ExpressionStepElementKeyIntExact(0), + pathStep: path.PathStepElementKeyInt(1), + expected: false, + }, + "StepElementKeyInt-equal": { + step: path.ExpressionStepElementKeyIntExact(0), + pathStep: path.PathStepElementKeyInt(0), + expected: true, + }, + "StepElementKeyString": { + step: path.ExpressionStepElementKeyIntExact(0), + pathStep: path.PathStepElementKeyString("test"), + expected: false, + }, + "StepElementKeyValue": { + step: path.ExpressionStepElementKeyIntExact(0), + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Matches(testCase.pathStep) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyIntExactString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyIntExact + expected string + }{ + "basic": { + step: path.ExpressionStepElementKeyIntExact(0), + expected: "[0]", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_step_element_key_string_any.go b/path/expression_step_element_key_string_any.go new file mode 100644 index 000000000..6a96201c5 --- /dev/null +++ b/path/expression_step_element_key_string_any.go @@ -0,0 +1,35 @@ +package path + +// Ensure ExpressionStepElementKeyStringAny satisfies the ExpressionStep +// interface. +var _ ExpressionStep = ExpressionStepElementKeyStringAny{} + +// ExpressionStepElementKeyStringAny is an attribute path expression for a matching any +// string key within a map. +type ExpressionStepElementKeyStringAny struct{} + +// Equal returns true if the given ExpressionStep is a +// ExpressionStepElementKeyStringAny. +func (s ExpressionStepElementKeyStringAny) Equal(o ExpressionStep) bool { + _, ok := o.(ExpressionStepElementKeyStringAny) + + return ok +} + +// Matches returns true if the given PathStep is fulfilled by the +// ExpressionStepElementKeyStringAny condition. +func (s ExpressionStepElementKeyStringAny) Matches(pathStep PathStep) bool { + _, ok := pathStep.(PathStepElementKeyString) + + return ok +} + +// String returns the human-readable representation of the element key +// expression. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +func (s ExpressionStepElementKeyStringAny) String() string { + return `["*"]` +} + +// unexported satisfies the Step interface. +func (s ExpressionStepElementKeyStringAny) unexported() {} diff --git a/path/expression_step_element_key_string_any_test.go b/path/expression_step_element_key_string_any_test.go new file mode 100644 index 000000000..933469ea8 --- /dev/null +++ b/path/expression_step_element_key_string_any_test.go @@ -0,0 +1,132 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepElementKeyStringAnyEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyStringAny + other path.ExpressionStep + expected bool + }{ + "ExpressionStepAttributeNameExact": { + step: path.ExpressionStepElementKeyStringAny{}, + other: path.ExpressionStepAttributeNameExact("test"), + expected: false, + }, + "ExpressionStepElementKeyIntExact": { + step: path.ExpressionStepElementKeyStringAny{}, + other: path.ExpressionStepElementKeyIntExact(0), + expected: false, + }, + "ExpressionStepElementKeyStringAny": { + step: path.ExpressionStepElementKeyStringAny{}, + other: path.ExpressionStepElementKeyStringAny{}, + expected: true, + }, + "ExpressionStepElementKeyStringExact": { + step: path.ExpressionStepElementKeyStringAny{}, + other: path.ExpressionStepElementKeyStringExact("test"), + expected: false, + }, + "ExpressionStepElementKeyValueExact": { + step: path.ExpressionStepElementKeyStringAny{}, + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyStringAnyMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyStringAny + pathStep path.PathStep + expected bool + }{ + "StepAttributeName": { + step: path.ExpressionStepElementKeyStringAny{}, + pathStep: path.PathStepAttributeName("test"), + expected: false, + }, + "StepElementKeyInt": { + step: path.ExpressionStepElementKeyStringAny{}, + pathStep: path.PathStepElementKeyInt(0), + expected: false, + }, + "StepElementKeyString": { + step: path.ExpressionStepElementKeyStringAny{}, + pathStep: path.PathStepElementKeyString("test"), + expected: true, + }, + "StepElementKeyValue": { + step: path.ExpressionStepElementKeyStringAny{}, + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Matches(testCase.pathStep) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyStringAnyString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyStringAny + expected string + }{ + "basic": { + step: path.ExpressionStepElementKeyStringAny{}, + expected: `["*"]`, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_step_element_key_string_exact.go b/path/expression_step_element_key_string_exact.go new file mode 100644 index 000000000..cf8120816 --- /dev/null +++ b/path/expression_step_element_key_string_exact.go @@ -0,0 +1,47 @@ +package path + +import ( + "fmt" +) + +// Ensure ExpressionStepElementKeyStringExact satisfies the ExpressionStep +// interface. +var _ ExpressionStep = ExpressionStepElementKeyStringExact("") + +// ExpressionStepElementKeyStringExact is an attribute path expression for an exact string +// key within a map. Map keys are always strings. +type ExpressionStepElementKeyStringExact string + +// Equal returns true if the given ExpressionStep is a +// ExpressionStepElementKeyStringExact and the string element key is equivalent. +func (s ExpressionStepElementKeyStringExact) Equal(o ExpressionStep) bool { + other, ok := o.(ExpressionStepElementKeyStringExact) + + if !ok { + return false + } + + return string(s) == string(other) +} + +// Matches returns true if the given PathStep is fulfilled by the +// ExpressionStepElementKeyStringExact condition. +func (s ExpressionStepElementKeyStringExact) Matches(pathStep PathStep) bool { + pathStepElementKeyString, ok := pathStep.(PathStepElementKeyString) + + if !ok { + return false + } + + return string(s) == string(pathStepElementKeyString) +} + +// String returns the human-readable representation of the element key +// expression. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +func (s ExpressionStepElementKeyStringExact) String() string { + return fmt.Sprintf("[%q]", string(s)) +} + +// unexported satisfies the Step interface. +func (s ExpressionStepElementKeyStringExact) unexported() {} diff --git a/path/expression_step_element_key_string_exact_test.go b/path/expression_step_element_key_string_exact_test.go new file mode 100644 index 000000000..13c8cebd0 --- /dev/null +++ b/path/expression_step_element_key_string_exact_test.go @@ -0,0 +1,141 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepElementKeyStringExactEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyStringExact + other path.ExpressionStep + expected bool + }{ + "ExpressionStepAttributeNameExact": { + step: path.ExpressionStepElementKeyStringExact("test"), + other: path.ExpressionStepAttributeNameExact("test"), + expected: false, + }, + "ExpressionStepElementKeyIntExact": { + step: path.ExpressionStepElementKeyStringExact("test"), + other: path.ExpressionStepElementKeyIntExact(1), + expected: false, + }, + "ExpressionStepElementKeyStringExact-different": { + step: path.ExpressionStepElementKeyStringExact("test"), + other: path.ExpressionStepElementKeyStringExact("not-test"), + expected: false, + }, + "ExpressionStepElementKeyStringExact-equal": { + step: path.ExpressionStepElementKeyStringExact("test"), + other: path.ExpressionStepElementKeyStringExact("test"), + expected: true, + }, + "ExpressionStepElementKeyValueExact": { + step: path.ExpressionStepElementKeyStringExact("test"), + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyStringExactMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyStringExact + pathStep path.PathStep + expected bool + }{ + "StepAttributeName": { + step: path.ExpressionStepElementKeyStringExact("test"), + pathStep: path.PathStepAttributeName("test"), + expected: false, + }, + "StepElementKeyInt": { + step: path.ExpressionStepElementKeyStringExact("test"), + pathStep: path.PathStepElementKeyInt(0), + expected: false, + }, + "StepElementKeyString-different": { + step: path.ExpressionStepElementKeyStringExact("test"), + pathStep: path.PathStepElementKeyString("not-test"), + expected: false, + }, + "StepElementKeyString-equal": { + step: path.ExpressionStepElementKeyStringExact("test"), + pathStep: path.PathStepElementKeyString("test"), + expected: true, + }, + "StepElementKeyValue": { + step: path.ExpressionStepElementKeyStringExact("test"), + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Matches(testCase.pathStep) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyStringExactString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyStringExact + expected string + }{ + "basic": { + step: path.ExpressionStepElementKeyStringExact("test"), + expected: `["test"]`, + }, + "quotes": { + step: path.ExpressionStepElementKeyStringExact(`testing is "fun"`), + expected: `["testing is \"fun\""]`, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_step_element_key_value_any.go b/path/expression_step_element_key_value_any.go new file mode 100644 index 000000000..26e93ae26 --- /dev/null +++ b/path/expression_step_element_key_value_any.go @@ -0,0 +1,35 @@ +package path + +// Ensure ExpressionStepElementKeyValueAny satisfies the ExpressionStep +// interface. +var _ ExpressionStep = ExpressionStepElementKeyValueAny{} + +// ExpressionStepElementKeyValueAny is an attribute path expression for a matching any +// Value element within a set. +type ExpressionStepElementKeyValueAny struct{} + +// Equal returns true if the given ExpressionStep is a +// ExpressionStepElementKeyValueAny. +func (s ExpressionStepElementKeyValueAny) Equal(o ExpressionStep) bool { + _, ok := o.(ExpressionStepElementKeyValueAny) + + return ok +} + +// Matches returns true if the given PathStep is fulfilled by the +// ExpressionStepElementKeyValueAny condition. +func (s ExpressionStepElementKeyValueAny) Matches(pathStep PathStep) bool { + _, ok := pathStep.(PathStepElementKeyValue) + + return ok +} + +// String returns the human-readable representation of the element key +// expression. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +func (s ExpressionStepElementKeyValueAny) String() string { + return "[Value(*)]" +} + +// unexported satisfies the Step interface. +func (s ExpressionStepElementKeyValueAny) unexported() {} diff --git a/path/expression_step_element_key_value_any_test.go b/path/expression_step_element_key_value_any_test.go new file mode 100644 index 000000000..2502bbef3 --- /dev/null +++ b/path/expression_step_element_key_value_any_test.go @@ -0,0 +1,132 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepElementKeyValueAnyEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyValueAny + other path.ExpressionStep + expected bool + }{ + "ExpressionStepAttributeNameExact": { + step: path.ExpressionStepElementKeyValueAny{}, + other: path.ExpressionStepAttributeNameExact("test"), + expected: false, + }, + "ExpressionStepElementKeyIntExact": { + step: path.ExpressionStepElementKeyValueAny{}, + other: path.ExpressionStepElementKeyIntExact(0), + expected: false, + }, + "ExpressionStepElementKeyStringExact": { + step: path.ExpressionStepElementKeyValueAny{}, + other: path.ExpressionStepElementKeyStringExact("test"), + expected: false, + }, + "ExpressionStepElementKeyValueAny": { + step: path.ExpressionStepElementKeyValueAny{}, + other: path.ExpressionStepElementKeyValueAny{}, + expected: true, + }, + "ExpressionStepElementKeyValueExact": { + step: path.ExpressionStepElementKeyValueAny{}, + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyValueAnyMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyValueAny + pathStep path.PathStep + expected bool + }{ + "StepAttributeName": { + step: path.ExpressionStepElementKeyValueAny{}, + pathStep: path.PathStepAttributeName("test"), + expected: false, + }, + "StepElementKeyInt": { + step: path.ExpressionStepElementKeyValueAny{}, + pathStep: path.PathStepElementKeyInt(0), + expected: false, + }, + "StepElementKeyString": { + step: path.ExpressionStepElementKeyValueAny{}, + pathStep: path.PathStepElementKeyString("test"), + expected: false, + }, + "StepElementKeyValue": { + step: path.ExpressionStepElementKeyValueAny{}, + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Matches(testCase.pathStep) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyValueAnyString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyValueAny + expected string + }{ + "basic": { + step: path.ExpressionStepElementKeyValueAny{}, + expected: "[Value(*)]", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_step_element_key_value_exact.go b/path/expression_step_element_key_value_exact.go new file mode 100644 index 000000000..d60842cd9 --- /dev/null +++ b/path/expression_step_element_key_value_exact.go @@ -0,0 +1,51 @@ +package path + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" +) + +// Ensure ExpressionStepElementKeyValueExact satisfies the Step interface. +// var _ Step = ExpressionStepElementKeyValueExact(/* ... */) + +// ExpressionStepElementKeyValueExact is an attribute path expression for an exact Value +// element within a set. Sets do not use integer-based indexing. +type ExpressionStepElementKeyValueExact struct { + // Value is an interface, so it cannot be type aliased with methods. + attr.Value +} + +// Equal returns true if the given ExpressionStep is a +// ExpressionStepElementKeyValueExact and the Value element key is equivalent. +func (s ExpressionStepElementKeyValueExact) Equal(o ExpressionStep) bool { + other, ok := o.(ExpressionStepElementKeyValueExact) + + if !ok { + return false + } + + return s.Value.Equal(other.Value) +} + +// Matches returns true if the given PathStep is fulfilled by the +// ExpressionStepElementKeyValueExact condition. +func (s ExpressionStepElementKeyValueExact) Matches(pathStep PathStep) bool { + pathStepElementKeyValue, ok := pathStep.(PathStepElementKeyValue) + + if !ok { + return false + } + + return s.Value.Equal(pathStepElementKeyValue.Value) +} + +// String returns the human-readable representation of the element key +// expression. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +func (s ExpressionStepElementKeyValueExact) String() string { + return fmt.Sprintf("[Value(%s)]", s.Value.String()) +} + +// unexported satisfies the Step interface. +func (s ExpressionStepElementKeyValueExact) unexported() {} diff --git a/path/expression_step_element_key_value_exact_test.go b/path/expression_step_element_key_value_exact_test.go new file mode 100644 index 000000000..78e041416 --- /dev/null +++ b/path/expression_step_element_key_value_exact_test.go @@ -0,0 +1,191 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepElementKeyValueExactEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyValueExact + other path.ExpressionStep + expected bool + }{ + "ExpressionStepAttributeNameExact": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + other: path.ExpressionStepAttributeNameExact("test"), + expected: false, + }, + "ExpressionStepElementKeyIntExact": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + other: path.ExpressionStepElementKeyIntExact(0), + expected: false, + }, + "ExpressionStepElementKeyStringExact": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + other: path.ExpressionStepElementKeyStringExact("test"), + expected: false, + }, + "ExpressionStepElementKeyValueExact-different": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "not-test"}}, + expected: false, + }, + "ExpressionStepElementKeyValueExact-equal": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyValueExactMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyValueExact + pathStep path.PathStep + expected bool + }{ + "StepAttributeName": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + pathStep: path.PathStepAttributeName("test"), + expected: false, + }, + "StepElementKeyInt": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + pathStep: path.PathStepElementKeyInt(0), + expected: false, + }, + "StepElementKeyString": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + pathStep: path.PathStepElementKeyString("test"), + expected: false, + }, + "StepElementKeyValue-different": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "not-test"}}, + expected: false, + }, + "StepElementKeyValue-equal": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Matches(testCase.pathStep) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepElementKeyValueExactString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepElementKeyValueExact + expected string + }{ + "bool-value": { + step: path.ExpressionStepElementKeyValueExact{Value: types.Bool{Value: true}}, + expected: `[Value(true)]`, + }, + "float64-value": { + step: path.ExpressionStepElementKeyValueExact{Value: types.Float64{Value: 1.2}}, + expected: `[Value(1.200000)]`, + }, + "int64-value": { + step: path.ExpressionStepElementKeyValueExact{Value: types.Int64{Value: 123}}, + expected: `[Value(123)]`, + }, + "list-value": { + step: path.ExpressionStepElementKeyValueExact{Value: types.List{ + Elems: []attr.Value{ + types.String{Value: "test-element-1"}, + types.String{Value: "test-element-2"}, + }, + ElemType: types.StringType, + }}, + expected: `[Value(["test-element-1","test-element-2"])]`, + }, + "map-value": { + step: path.ExpressionStepElementKeyValueExact{Value: types.Map{ + Elems: map[string]attr.Value{ + "test-key-1": types.String{Value: "test-value-1"}, + "test-key-2": types.String{Value: "test-value-2"}, + }, + ElemType: types.StringType, + }}, + expected: `[Value({"test-key-1":"test-value-1","test-key-2":"test-value-2"})]`, + }, + "object-value": { + step: path.ExpressionStepElementKeyValueExact{Value: types.Object{ + Attrs: map[string]attr.Value{ + "test_attr_1": types.Bool{Value: true}, + "test_attr_2": types.String{Value: "test-value"}, + }, + AttrTypes: map[string]attr.Type{ + "test_attr_1": types.BoolType, + "test_attr_2": types.StringType, + }, + }}, + expected: `[Value({"test_attr_1":true,"test_attr_2":"test-value"})]`, + }, + "string-null": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Null: true}}, + expected: `[Value()]`, + }, + "string-unknown": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Unknown: true}}, + expected: `[Value()]`, + }, + "string-value": { + step: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: `[Value("test")]`, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_step_parent.go b/path/expression_step_parent.go new file mode 100644 index 000000000..9590ad0bd --- /dev/null +++ b/path/expression_step_parent.go @@ -0,0 +1,35 @@ +package path + +// Ensure StepParent satisfies the ExpressionStep interface. +var _ ExpressionStep = ExpressionStepParent{} + +// StepParent is an attribute path expression for a traversing to the parent +// attribute path relative to the current one. This is intended only for the +// start of attribute-level expressions which will be combined with the current +// attribute path being called. +type ExpressionStepParent struct{} + +// Equal returns true if the given ExpressionStep is a ExpressionStepParent. +func (s ExpressionStepParent) Equal(o ExpressionStep) bool { + _, ok := o.(ExpressionStepParent) + + return ok +} + +// Matches returns true if the given PathStep is fulfilled by the +// ExpressionStepParent condition. +func (s ExpressionStepParent) Matches(_ PathStep) bool { + // This return value should have no effect, as this Step is a + // sentinel type, rather than one that should be used in matching. + return false +} + +// String returns the human-readable representation of the element key +// expression. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +func (s ExpressionStepParent) String() string { + return "<" +} + +// unexported satisfies the Step interface. +func (s ExpressionStepParent) unexported() {} diff --git a/path/expression_step_parent_test.go b/path/expression_step_parent_test.go new file mode 100644 index 000000000..87701c1a7 --- /dev/null +++ b/path/expression_step_parent_test.go @@ -0,0 +1,132 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepParentEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepParent + other path.ExpressionStep + expected bool + }{ + "ExpressionStepAttributeNameExact": { + step: path.ExpressionStepParent{}, + other: path.ExpressionStepAttributeNameExact("test"), + expected: false, + }, + "ExpressionStepElementKeyIntExact": { + step: path.ExpressionStepParent{}, + other: path.ExpressionStepElementKeyIntExact(0), + expected: false, + }, + "ExpressionStepElementKeyStringExact": { + step: path.ExpressionStepParent{}, + other: path.ExpressionStepElementKeyStringExact("test"), + expected: false, + }, + "ExpressionStepElementKeyValueExact": { + step: path.ExpressionStepParent{}, + other: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + expected: false, + }, + "StepParent": { + step: path.ExpressionStepParent{}, + other: path.ExpressionStepParent{}, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepParentMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepParent + pathStep path.PathStep + expected bool + }{ + "StepAttributeName": { + step: path.ExpressionStepParent{}, + pathStep: path.PathStepAttributeName("test"), + expected: false, + }, + "StepElementKeyInt": { + step: path.ExpressionStepParent{}, + pathStep: path.PathStepElementKeyInt(0), + expected: false, + }, + "StepElementKeyString": { + step: path.ExpressionStepParent{}, + pathStep: path.PathStepElementKeyString("test"), + expected: false, + }, + "StepElementKeyValue": { + step: path.ExpressionStepParent{}, + pathStep: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.Matches(testCase.pathStep) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepParentString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.ExpressionStepParent + expected string + }{ + "basic": { + step: path.ExpressionStepParent{}, + expected: "<", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_steps.go b/path/expression_steps.go new file mode 100644 index 000000000..7ee79b01d --- /dev/null +++ b/path/expression_steps.go @@ -0,0 +1,155 @@ +package path + +import "strings" + +// ExpressionSteps represents an ordered collection of attribute path +// expressions. +type ExpressionSteps []ExpressionStep + +// Append adds the given ExpressionSteps to the end of the previous ExpressionSteps and +// returns the combined result. +func (s *ExpressionSteps) Append(steps ...ExpressionStep) ExpressionSteps { + if s == nil { + return steps + } + + *s = append(*s, steps...) + + return *s +} + +// Copy returns a duplicate of the steps that is safe to modify without +// affecting the original. Returns nil if the original steps is nil. +func (s ExpressionSteps) Copy() ExpressionSteps { + if s == nil { + return nil + } + + copiedExpressionSteps := make(ExpressionSteps, len(s)) + + copy(copiedExpressionSteps, s) + + return copiedExpressionSteps +} + +// Equal returns true if the given ExpressionSteps are equivalent. +func (s ExpressionSteps) Equal(o ExpressionSteps) bool { + if len(s) != len(o) { + return false + } + + for stepIndex, step := range s { + if !step.Equal(o[stepIndex]) { + return false + } + } + + return true +} + +// LastStep returns the final ExpressionStep and the remaining ExpressionSteps. +func (s ExpressionSteps) LastStep() (ExpressionStep, ExpressionSteps) { + if len(s) == 0 { + return nil, ExpressionSteps{} + } + + if len(s) == 1 { + return s[0], ExpressionSteps{} + } + + return s[len(s)-1], s[:len(s)-1] +} + +// Matches returns true if the given PathSteps match each ExpressionStep. +// +// Any ExpressionStepParent will automatically be resolved. +func (s ExpressionSteps) Matches(pathSteps PathSteps) bool { + resolvedExpressionSteps := s.Resolve() + + // Empty expression should not match anything to prevent false positives. + if len(resolvedExpressionSteps) == 0 { + return false + } + + if len(resolvedExpressionSteps) != len(pathSteps) { + return false + } + + for stepIndex, expressionStep := range resolvedExpressionSteps { + if !expressionStep.Matches(pathSteps[stepIndex]) { + return false + } + } + + return true +} + +// NextStep returns the first ExpressionStep and the remaining ExpressionSteps. +func (s ExpressionSteps) NextStep() (ExpressionStep, ExpressionSteps) { + if len(s) == 0 { + return nil, s + } + + return s[0], s[1:] +} + +// Resolve returns a copy of ExpressionSteps without any ExpressionStepParent. +// +// Returns empty ExpressionSteps if any ExpressionStepParent attempt to go +// beyond the first element. Returns nil if the original steps is nil. +func (s ExpressionSteps) Resolve() ExpressionSteps { + if s == nil { + return nil + } + + result := make(ExpressionSteps, 0, len(s)) + + // This might not be the most efficient or prettiest algorithm, but it + // works for now. + for _, step := range s { + _, ok := step.(ExpressionStepParent) + + if !ok { + result.Append(step) + + continue + } + + // Allow parent traversal up to the root, but not beyond. + if len(result) == 0 { + return ExpressionSteps{} + } + + _, remaining := result.LastStep() + + if len(remaining) == 0 { + result = ExpressionSteps{} + + continue + } + + result = remaining + } + + return result +} + +// String returns the human-readable representation of the ExpressionSteps. +// It is intended for logging and error messages and is not protected by +// compatibility guarantees. +func (s ExpressionSteps) String() string { + var result strings.Builder + + for stepIndex, step := range s { + if stepIndex != 0 { + switch step.(type) { + case ExpressionStepAttributeNameExact, ExpressionStepParent: + result.WriteString(".") + } + } + + result.WriteString(step.String()) + } + + return result.String() +} diff --git a/path/expression_steps_test.go b/path/expression_steps_test.go new file mode 100644 index 000000000..751dc3455 --- /dev/null +++ b/path/expression_steps_test.go @@ -0,0 +1,990 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionStepsAppend(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.ExpressionSteps + add path.ExpressionSteps + expected path.ExpressionSteps + }{ + "empty-empty": { + steps: path.ExpressionSteps{}, + add: path.ExpressionSteps{}, + expected: path.ExpressionSteps{}, + }, + "empty-nonempty": { + steps: path.ExpressionSteps{}, + add: path.ExpressionSteps{path.ExpressionStepAttributeNameExact("test")}, + expected: path.ExpressionSteps{path.ExpressionStepAttributeNameExact("test")}, + }, + "nonempty-empty": { + steps: path.ExpressionSteps{path.ExpressionStepAttributeNameExact("test")}, + add: path.ExpressionSteps{}, + expected: path.ExpressionSteps{path.ExpressionStepAttributeNameExact("test")}, + }, + "nonempty-nonempty": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + add: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("add-test"), + path.ExpressionStepElementKeyStringExact("add-test-key"), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + path.ExpressionStepAttributeNameExact("add-test"), + path.ExpressionStepElementKeyStringExact("add-test-key"), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.steps.Append(testCase.add...) + + if diff := cmp.Diff(testCase.steps, testCase.expected); diff != "" { + t.Errorf("unexpected original difference: %s", diff) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected result difference: %s", diff) + } + }) + } +} + +func TestExpressionStepsCopy(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.ExpressionSteps + expected path.ExpressionSteps + }{ + "nil": { + steps: nil, + expected: nil, + }, + "empty": { + steps: path.ExpressionSteps{}, + expected: path.ExpressionSteps{}, + }, + "shallow": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + }, + "deep": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepElementKeyIntExact(0), + path.ExpressionStepAttributeNameExact("test2"), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepElementKeyIntExact(0), + path.ExpressionStepAttributeNameExact("test2"), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.steps.Copy() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + // Ensure original is not modified + got.Append(path.ExpressionStepAttributeNameExact("modify-test")) + + if diff := cmp.Diff(got, testCase.expected); diff == "" { + t.Error("unexpected modification") + } + }) + } +} + +func TestExpressionStepsEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.ExpressionSteps + other path.ExpressionSteps + expected bool + }{ + "nil-nil": { + steps: nil, + other: nil, + expected: true, + }, + "empty-empty": { + steps: path.ExpressionSteps{}, + other: path.ExpressionSteps{}, + expected: true, + }, + "different-length": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + }, + expected: false, + }, + "StepAttributeName-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("not-test"), + }, + expected: false, + }, + "StepAttributeName-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + expected: true, + }, + "StepAttributeName-StepElementKeyInt-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(1), + }, + expected: false, + }, + "StepAttributeName-StepElementKeyInt-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + expected: true, + }, + "StepAttributeName-StepElementKeyString-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringExact("test-key"), + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringExact("not-test-key"), + }, + expected: false, + }, + "StepAttributeName-StepElementKeyString-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringExact("test-key"), + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringExact("test-key"), + }, + expected: true, + }, + "StepAttributeName-StepElementKeyValue-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "not-test-value"}}, + }, + expected: false, + }, + "StepAttributeName-StepElementKeyValue-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + other: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + expected: true, + }, + "StepElementKeyInt-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyIntExact(0), + }, + other: path.ExpressionSteps{ + path.ExpressionStepElementKeyIntExact(1), + }, + expected: false, + }, + "StepElementKeyInt-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyIntExact(0), + }, + other: path.ExpressionSteps{ + path.ExpressionStepElementKeyIntExact(0), + }, + expected: true, + }, + "StepElementKeyString-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyStringExact("test"), + }, + other: path.ExpressionSteps{ + path.ExpressionStepElementKeyStringExact("not-test"), + }, + expected: false, + }, + "StepElementKeyString-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyStringExact("test"), + }, + other: path.ExpressionSteps{ + path.ExpressionStepElementKeyStringExact("test"), + }, + expected: true, + }, + "StepElementKeyValue-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + other: path.ExpressionSteps{ + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "not-test-value"}}, + }, + expected: false, + }, + "StepElementKeyValue-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + other: path.ExpressionSteps{ + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.steps.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionStepsLastStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.ExpressionSteps + expectedLastStep path.ExpressionStep + expectedRemaining path.ExpressionSteps + }{ + "nil": { + steps: nil, + expectedLastStep: nil, + expectedRemaining: nil, + }, + "empty": { + steps: path.ExpressionSteps{}, + expectedLastStep: nil, + expectedRemaining: nil, + }, + "one": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + expectedLastStep: path.ExpressionStepAttributeNameExact("test"), + expectedRemaining: nil, + }, + "two": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + expectedLastStep: path.ExpressionStepElementKeyIntExact(0), + expectedRemaining: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + }, + "three": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + path.ExpressionStepAttributeNameExact("nested-test"), + }, + expectedLastStep: path.ExpressionStepAttributeNameExact("nested-test"), + expectedRemaining: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + gotLastStep, gotRemaining := testCase.steps.LastStep() + + if diff := cmp.Diff(gotLastStep, testCase.expectedLastStep); diff != "" { + t.Errorf("unexpected last step difference: %s", diff) + } + + if diff := cmp.Diff(gotRemaining, testCase.expectedRemaining); diff != "" { + t.Errorf("unexpected remaining difference: %s", diff) + } + }) + } +} + +func TestExpressionStepsMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.ExpressionSteps + pathSteps path.PathSteps + expected bool + }{ + "empty-empty": { + steps: path.ExpressionSteps{}, + pathSteps: path.PathSteps{}, + expected: false, + }, + "empty-nonempty": { + steps: path.ExpressionSteps{}, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + }, + expected: false, + }, + "nonempty-empty": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + pathSteps: path.PathSteps{}, + expected: false, + }, + "AttributeNameExact-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("not-test"), + }, + expected: false, + }, + "AttributeNameExact-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + }, + expected: true, + }, + "AttributeNameExact-AttributeNameExact-different-firststep": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test2"), + path.PathStepAttributeName("test2"), + }, + expected: false, + }, + "AttributeNameExact-AttributeNameExact-different-laststep": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test1"), + path.PathStepAttributeName("test3"), + }, + expected: false, + }, + "AttributeNameExact-AttributeNameExact-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test1"), + path.PathStepAttributeName("test2"), + }, + expected: true, + }, + "AttributeNameExact-AttributeNameExact-Parent-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + path.ExpressionStepParent{}, + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test2"), + }, + expected: false, + }, + "AttributeNameExact-AttributeNameExact-Parent-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + path.ExpressionStepParent{}, + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test1"), + }, + expected: true, + }, + "AttributeNameExact-AttributeNameExact-Parent-AttributeNameExact-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test3"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test1"), + path.PathStepAttributeName("test2"), + }, + expected: false, + }, + "AttributeNameExact-AttributeNameExact-Parent-AttributeNameExact-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test3"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test1"), + path.PathStepAttributeName("test3"), + }, + expected: true, + }, + "AttributeNameExact-ElementKeyIntAny": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntAny{}, + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyInt(0), + }, + expected: true, + }, + "AttributeNameExact-ElementKeyIntExact-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyInt(1), + }, + expected: false, + }, + "AttributeNameExact-ElementKeyIntExact-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyInt(0), + }, + expected: true, + }, + "AttributeNameExact-ElementKeyStringAny": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringAny{}, + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyString("test-key"), + }, + expected: true, + }, + "AttributeNameExact-ElementKeyStringExact-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringExact("test-key"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyString("not-test-key"), + }, + expected: false, + }, + "AttributeNameExact-ElementKeyStringExact-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringExact("test-key"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyString("test-key"), + }, + expected: true, + }, + "AttributeNameExact-ElementKeyValueAny": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueAny{}, + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyValue{Value: types.String{Value: "test-value"}}, + }, + expected: true, + }, + "AttributeNameExact-ElementKeyValueExact-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyValue{Value: types.String{Value: "not-test-value"}}, + }, + expected: false, + }, + "AttributeNameExact-ElementKeyValueExact-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyValue{Value: types.String{Value: "test-value"}}, + }, + expected: true, + }, + "AttributeNameExact-Parent": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepParent{}, + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test1"), + }, + expected: false, + }, + "AttributeNameExact-Parent-AttributeNameExact-different": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test2"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test1"), + }, + expected: false, + }, + "AttributeNameExact-Parent-AttributeNameExact-equal": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test2"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test2"), + }, + expected: true, + }, + "Parent-AttributeNameExact": { + steps: path.ExpressionSteps{ + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test"), + }, + pathSteps: path.PathSteps{ + path.PathStepAttributeName("test"), + }, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.steps.Matches(testCase.pathSteps) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionStepsNextStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.ExpressionSteps + expectedNextStep path.ExpressionStep + expectedRemaining path.ExpressionSteps + }{ + "nil": { + steps: nil, + expectedNextStep: nil, + expectedRemaining: nil, + }, + "empty": { + steps: path.ExpressionSteps{}, + expectedNextStep: nil, + expectedRemaining: nil, + }, + "one": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + expectedNextStep: path.ExpressionStepAttributeNameExact("test"), + expectedRemaining: nil, + }, + "two": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + expectedNextStep: path.ExpressionStepAttributeNameExact("test"), + expectedRemaining: path.ExpressionSteps{ + path.ExpressionStepElementKeyIntExact(0), + }, + }, + "three": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + path.ExpressionStepAttributeNameExact("nested-test"), + }, + expectedNextStep: path.ExpressionStepAttributeNameExact("test"), + expectedRemaining: path.ExpressionSteps{ + path.ExpressionStepElementKeyIntExact(0), + path.ExpressionStepAttributeNameExact("nested-test"), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + gotNextStep, gotRemaining := testCase.steps.NextStep() + + if diff := cmp.Diff(gotNextStep, testCase.expectedNextStep); diff != "" { + t.Errorf("unexpected next step difference: %s", diff) + } + + if diff := cmp.Diff(gotRemaining, testCase.expectedRemaining); diff != "" { + t.Errorf("unexpected remaining difference: %s", diff) + } + }) + } +} + +func TestExpressionStepsResolve(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.ExpressionSteps + expected path.ExpressionSteps + }{ + "nil": { + steps: nil, + expected: nil, + }, + "empty": { + steps: path.ExpressionSteps{}, + expected: nil, + }, + "AttributeNameExact": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + }, + "AttributeNameExact-AttributeNameExact": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + }, + }, + "AttributeNameExact-AttributeNameExact-Parent": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + path.ExpressionStepParent{}, + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + }, + }, + "AttributeNameExact-AttributeNameExact-Parent-AttributeNameExact": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test3"), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test3"), + }, + }, + "AttributeNameExact-Parent": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepParent{}, + }, + expected: path.ExpressionSteps{}, + }, + "AttributeNameExact-Parent-AttributeNameExact": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test2"), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test2"), + }, + }, + "Parent": { + steps: path.ExpressionSteps{ + path.ExpressionStepParent{}, + }, + expected: nil, + }, + "Parent-AttributeNameExact": { + steps: path.ExpressionSteps{ + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test"), + }, + expected: nil, + }, + "Parent-Parent": { + steps: path.ExpressionSteps{ + path.ExpressionStepParent{}, + path.ExpressionStepParent{}, + }, + expected: nil, + }, + "Parent-Parent-AttributeNameExact": { + steps: path.ExpressionSteps{ + path.ExpressionStepParent{}, + path.ExpressionStepParent{}, + path.ExpressionStepAttributeNameExact("test"), + }, + expected: nil, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.steps.Resolve() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionStepsString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.ExpressionSteps + expected string + }{ + "nil": { + steps: nil, + expected: ``, + }, + "empty": { + steps: path.ExpressionSteps{}, + expected: ``, + }, + "AttributeName": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + expected: `test`, + }, + "AttributeName-AttributeName": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + }, + expected: `test1.test2`, + }, + "AttributeName-AttributeName-AttributeName": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepAttributeNameExact("test2"), + path.ExpressionStepAttributeNameExact("test3"), + }, + expected: `test1.test2.test3`, + }, + "AttributeName-ElementKeyInt": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + }, + expected: `test[0]`, + }, + "AttributeName-ElementKeyInt-AttributeName": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepElementKeyIntExact(0), + path.ExpressionStepAttributeNameExact("test2"), + }, + expected: `test1[0].test2`, + }, + "AttributeName-ElementKeyInt-ElementKeyInt": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(0), + path.ExpressionStepElementKeyIntExact(1), + }, + expected: `test[0][1]`, + }, + "AttributeName-ElementKeyString": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringExact("test-key"), + }, + expected: `test["test-key"]`, + }, + "AttributeName-ElementKeyString-AttributeName": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test1"), + path.ExpressionStepElementKeyStringExact("test-key"), + path.ExpressionStepAttributeNameExact("test2"), + }, + expected: `test1["test-key"].test2`, + }, + "AttributeName-ElementKeyString-ElementKeyString": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyStringExact("test-key1"), + path.ExpressionStepElementKeyStringExact("test-key2"), + }, + expected: `test["test-key1"]["test-key2"]`, + }, + "AttributeName-ElementKeyValue": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test-value"}}, + }, + expected: `test[Value("test-value")]`, + }, + "AttributeName-ElementKeyValue-AttributeName": { + steps: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyValueExact{Value: types.Object{ + Attrs: map[string]attr.Value{ + "test_attr_1": types.Bool{Value: true}, + "test_attr_2": types.String{Value: "test-value"}, + }, + AttrTypes: map[string]attr.Type{ + "test_attr_1": types.BoolType, + "test_attr_2": types.StringType, + }, + }}, + path.ExpressionStepAttributeNameExact("test_attr_1"), + }, + expected: `test[Value({"test_attr_1":true,"test_attr_2":"test-value"})].test_attr_1`, + }, + "ElementKeyInt": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyIntExact(0), + }, + expected: `[0]`, + }, + "ElementKeyString": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyStringExact("test"), + }, + expected: `["test"]`, + }, + "ElementKeyValue": { + steps: path.ExpressionSteps{ + path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + }, + expected: `[Value("test")]`, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.steps.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expression_test.go b/path/expression_test.go new file mode 100644 index 000000000..a9aa7011a --- /dev/null +++ b/path/expression_test.go @@ -0,0 +1,732 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestExpressionAtAnyListIndex(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test"), + expected: path.MatchRoot("test").AtAnyListIndex(), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2").AtAnyListIndex(), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.AtAnyListIndex() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionAtAnyMapKey(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test"), + expected: path.MatchRoot("test").AtAnyMapKey(), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2").AtAnyMapKey(), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.AtAnyMapKey() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionAtAnySetValue(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test"), + expected: path.MatchRoot("test").AtAnySetValue(), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2").AtAnySetValue(), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.AtAnySetValue() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionAtListIndex(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + index int + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test"), + index: 1, + expected: path.MatchRoot("test").AtListIndex(1), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + index: 1, + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2").AtListIndex(1), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.AtListIndex(testCase.index) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionAtMapKey(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + key string + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test"), + key: "test-key", + expected: path.MatchRoot("test").AtMapKey("test-key"), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + key: "test-key", + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2").AtMapKey("test-key"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.AtMapKey(testCase.key) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionAtName(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + name string + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test1"), + name: "test2", + expected: path.MatchRoot("test1").AtName("test2"), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0), + name: "test2", + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.AtName(testCase.name) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionAtParent(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test"), + expected: path.MatchRoot("test").AtParent(), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2").AtParent(), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.AtParent() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionAtSetValue(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + value attr.Value + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test"), + value: types.String{Value: "test"}, + expected: path.MatchRoot("test").AtSetValue(types.String{Value: "test"}), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + value: types.String{Value: "test"}, + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2").AtSetValue(types.String{Value: "test"}), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.AtSetValue(testCase.value) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionCopy(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + expected path.Expression + }{ + "shallow": { + expression: path.MatchRoot("test"), + expected: path.MatchRoot("test"), + }, + "deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + expected: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.Copy() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + other path.Expression + expected bool + }{ + "different-length": { + expression: path.MatchRoot("test1").AtName("test2"), + other: path.MatchRoot("test1"), + expected: false, + }, + "different-step-shallow": { + expression: path.MatchRoot("test"), + other: path.MatchRoot("not-test"), + expected: false, + }, + "different-step-deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + other: path.MatchRoot("test2").AtListIndex(0).AtName("not-test2"), + expected: false, + }, + "equal-shallow": { + expression: path.MatchRoot("test"), + other: path.MatchRoot("test"), + expected: true, + }, + "equal-deep": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + other: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.Equal(testCase.other) + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func TestExpressionMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + path path.Path + expected bool + }{ + "empty-empty": { + expression: path.Expression{}, + path: path.Empty(), + expected: false, + }, + "empty-nonempty": { + expression: path.Expression{}, + path: path.Root("test"), + expected: false, + }, + "nonempty-empty": { + expression: path.MatchRoot("test"), + path: path.Empty(), + expected: false, + }, + "AttributeNameExact-different": { + expression: path.MatchRoot("test"), + path: path.Root("not-test"), + expected: false, + }, + "AttributeNameExact-equal": { + expression: path.MatchRoot("test"), + path: path.Root("test"), + expected: true, + }, + "AttributeNameExact-AttributeNameExact-different-firststep": { + expression: path.MatchRoot("test1").AtName("test2"), + path: path.Root("test2").AtName("test2"), + expected: false, + }, + "AttributeNameExact-AttributeNameExact-different-laststep": { + expression: path.MatchRoot("test1").AtName("test2"), + path: path.Root("test1").AtName("test3"), + expected: false, + }, + "AttributeNameExact-AttributeNameExact-equal": { + expression: path.MatchRoot("test1").AtName("test2"), + path: path.Root("test1").AtName("test2"), + expected: true, + }, + "AttributeNameExact-AttributeNameExact-Parent-different": { + expression: path.MatchRoot("test1").AtName("test2").AtParent(), + path: path.Root("test2"), + expected: false, + }, + "AttributeNameExact-AttributeNameExact-Parent-equal": { + expression: path.MatchRoot("test1").AtName("test2").AtParent(), + path: path.Root("test1"), + expected: true, + }, + "AttributeNameExact-AttributeNameExact-Parent-AttributeNameExact-different": { + expression: path.MatchRoot("test1").AtName("test2").AtParent().AtName("test3"), + path: path.Root("test1").AtName("test2"), + expected: false, + }, + "AttributeNameExact-AttributeNameExact-Parent-AttributeNameExact-equal": { + expression: path.MatchRoot("test1").AtName("test2").AtParent().AtName("test3"), + path: path.Root("test1").AtName("test3"), + expected: true, + }, + "AttributeNameExact-ElementKeyIntAny": { + expression: path.MatchRoot("test").AtAnyListIndex(), + path: path.Root("test").AtListIndex(0), + expected: true, + }, + "AttributeNameExact-ElementKeyIntExact-different": { + expression: path.MatchRoot("test").AtListIndex(0), + path: path.Root("test").AtListIndex(1), + expected: false, + }, + "AttributeNameExact-ElementKeyIntExact-equal": { + expression: path.MatchRoot("test").AtListIndex(0), + path: path.Root("test").AtListIndex(0), + expected: true, + }, + "AttributeNameExact-ElementKeyStringAny": { + expression: path.MatchRoot("test").AtAnyMapKey(), + path: path.Root("test").AtMapKey("test-key"), + expected: true, + }, + "AttributeNameExact-ElementKeyStringExact-different": { + expression: path.MatchRoot("test").AtMapKey("test-key"), + path: path.Root("test").AtMapKey("not-test-key"), + expected: false, + }, + "AttributeNameExact-ElementKeyStringExact-equal": { + expression: path.MatchRoot("test").AtMapKey("test-key"), + path: path.Root("test").AtMapKey("test-key"), + expected: true, + }, + "AttributeNameExact-ElementKeyValueAny": { + expression: path.MatchRoot("test").AtAnySetValue(), + path: path.Root("test").AtSetValue(types.String{Value: "test-value"}), + expected: true, + }, + "AttributeNameExact-ElementKeyValueExact-different": { + expression: path.MatchRoot("test").AtSetValue(types.String{Value: "test-value"}), + path: path.Root("test").AtSetValue(types.String{Value: "not-test-value"}), + expected: false, + }, + "AttributeNameExact-ElementKeyValueExact-equal": { + expression: path.MatchRoot("test").AtSetValue(types.String{Value: "test-value"}), + path: path.Root("test").AtSetValue(types.String{Value: "test-value"}), + expected: true, + }, + "AttributeNameExact-Parent-AttributeNameExact-different": { + expression: path.MatchRoot("test1").AtParent().AtName("test2"), + path: path.Root("test1"), + expected: false, + }, + "AttributeNameExact-Parent-AttributeNameExact-equal": { + expression: path.MatchRoot("test1").AtParent().AtName("test2"), + path: path.Root("test2"), + expected: true, + }, + "Parent-AttributeNameExact": { + expression: path.MatchRelative().AtParent().AtName("test"), + path: path.Root("test"), + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.Matches(testCase.path) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionMerge(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + other path.Expression + expected path.Expression + }{ + "Relative-further": { + expression: path.MatchRoot("test1"), + other: path.MatchRelative().AtName("test2"), + expected: path.MatchRoot("test1").AtName("test2"), + }, + "Relative-Parent-root-level": { + expression: path.MatchRoot("test1"), + other: path.MatchRelative().AtParent().AtName("test2"), + expected: path.MatchRoot("test1").AtParent().AtName("test2"), + }, + "Relative-Parent-nested-level": { + expression: path.MatchRoot("test_parent").AtListIndex(1).AtName("test_child1"), + other: path.MatchRelative().AtParent().AtName("test_child2"), + expected: path.MatchRoot("test_parent").AtListIndex(1).AtName("test_child1").AtParent().AtName("test_child2"), + }, + "Root": { + expression: path.MatchRoot("test1"), + other: path.MatchRoot("test2"), + expected: path.MatchRoot("test2"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.Merge(testCase.other) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected result difference: %s", diff) + } + }) + } +} + +func TestExpressionResolve(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + expected path.Expression + }{ + // Refer to TestExpressionStepsResolve for more exhaustive unit + // testing of the underlying step resolving functionality. + "AttributeNameExact": { + expression: path.MatchRoot("test1"), + expected: path.MatchRoot("test1"), + }, + "AttributeNameExact-AttributeNameExact": { + expression: path.MatchRoot("test1").AtName("test2"), + expected: path.MatchRoot("test1").AtName("test2"), + }, + "AttributeNameExact-Parent-AttributeNameExact": { + expression: path.MatchRoot("test1").AtParent().AtName("test2"), + expected: path.MatchRoot("test2"), + }, + "AttributeNameExact-ElementKeyIntExact-AttributeNameExact-Parent-AttributeNameExact": { + expression: path.MatchRoot("test_parent").AtListIndex(1).AtName("test_child1").AtParent().AtName("test_child2"), + expected: path.MatchRoot("test_parent").AtListIndex(1).AtName("test_child2"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.Resolve() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionSteps(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + expected path.ExpressionSteps + }{ + "one": { + expression: path.MatchRoot("test"), + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + }, + "two": { + expression: path.MatchRoot("test").AtListIndex(1), + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(1), + }, + }, + "any": { + expression: path.MatchRoot("test").AtAnyListIndex(), + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntAny{}, + }, + }, + "parent": { + expression: path.MatchRoot("test").AtParent(), + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepParent{}, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.Steps() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestExpressionString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expression path.Expression + expected string + }{ + "AttributeNameExact": { + expression: path.MatchRoot("test"), + expected: `test`, + }, + "AttributeNameExact-AttributeNameExact": { + expression: path.MatchRoot("test1").AtName("test2"), + expected: `test1.test2`, + }, + "AttributeNameExact-AttributeNameExact-AttributeNameExact": { + expression: path.MatchRoot("test1").AtName("test2").AtName("test3"), + expected: `test1.test2.test3`, + }, + "AttributeNameExact-ElementKeyIntAny": { + expression: path.MatchRoot("test").AtAnyListIndex(), + expected: `test[*]`, + }, + "AttributeNameExact-ElementKeyIntExact": { + expression: path.MatchRoot("test").AtListIndex(0), + expected: `test[0]`, + }, + "AttributeNameExact-ElementKeyIntExact-AttributeNameExact": { + expression: path.MatchRoot("test1").AtListIndex(0).AtName("test2"), + expected: `test1[0].test2`, + }, + "AttributeNameExact-ElementKeyIntExact-ElementKeyIntExact": { + expression: path.MatchRoot("test").AtListIndex(0).AtListIndex(1), + expected: `test[0][1]`, + }, + "AttributeNameExact-ElementKeyStringAny": { + expression: path.MatchRoot("test").AtAnyMapKey(), + expected: `test["*"]`, + }, + "AttributeNameExact-ElementKeyStringExact": { + expression: path.MatchRoot("test").AtMapKey("test-key"), + expected: `test["test-key"]`, + }, + "AttributeNameExact-ElementKeyStringExact-AttributeNameExact": { + expression: path.MatchRoot("test1").AtMapKey("test-key").AtName("test2"), + expected: `test1["test-key"].test2`, + }, + "AttributeNameExact-ElementKeyStringExact-ElementKeyStringExact": { + expression: path.MatchRoot("test").AtMapKey("test-key1").AtMapKey("test-key2"), + expected: `test["test-key1"]["test-key2"]`, + }, + "AttributeNameExact-ElementKeyValueAny": { + expression: path.MatchRoot("test").AtAnySetValue(), + expected: `test[Value(*)]`, + }, + "AttributeNameExact-ElementKeyValueExact": { + expression: path.MatchRoot("test").AtSetValue(types.String{Value: "test-value"}), + expected: `test[Value("test-value")]`, + }, + "AttributeNameExact-ElementKeyValue-AttributeNameExact": { + expression: path.MatchRoot("test").AtSetValue(types.Object{ + Attrs: map[string]attr.Value{ + "test_attr_1": types.Bool{Value: true}, + "test_attr_2": types.String{Value: "test-value"}, + }, + AttrTypes: map[string]attr.Type{ + "test_attr_1": types.BoolType, + "test_attr_2": types.StringType, + }, + }).AtName("test_attr_1"), + expected: `test[Value({"test_attr_1":true,"test_attr_2":"test-value"})].test_attr_1`, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expression.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/expressions.go b/path/expressions.go new file mode 100644 index 000000000..6aacbb6ec --- /dev/null +++ b/path/expressions.go @@ -0,0 +1,33 @@ +package path + +import "strings" + +// Expressions is a collection of attribute path expressions. +type Expressions []Expression + +// String returns the human-readable representation of the expression +// collection. It is intended for logging and error messages and is not +// protected by compatibility guarantees. +// +// Empty expressions are skipped. +func (p Expressions) String() string { + var result strings.Builder + + result.WriteString("[") + + for pathIndex, path := range p { + if path.Equal(Expression{}) { + continue + } + + if pathIndex != 0 { + result.WriteString(",") + } + + result.WriteString(path.String()) + } + + result.WriteString("]") + + return result.String() +} diff --git a/path/expressions_test.go b/path/expressions_test.go new file mode 100644 index 000000000..896c2bc76 --- /dev/null +++ b/path/expressions_test.go @@ -0,0 +1,66 @@ +package path_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +func TestExpressionsString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + expressions path.Expressions + expected string + }{ + "nil": { + expressions: nil, + expected: "[]", + }, + "empty": { + expressions: path.Expressions{}, + expected: "[]", + }, + "one": { + expressions: path.Expressions{ + path.MatchRoot("test"), + }, + expected: "[test]", + }, + "one-empty": { + expressions: path.Expressions{ + path.Expression{}, + }, + expected: "[]", + }, + "two": { + expressions: path.Expressions{ + path.MatchRoot("test1"), + path.MatchRoot("test2"), + }, + expected: "[test1,test2]", + }, + "two-empty": { + expressions: path.Expressions{ + path.MatchRoot("test"), + path.Expression{}, + }, + expected: "[test]", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.expressions.String() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/path/path.go b/path/path.go index d648fbd2d..c9dc1b0bd 100644 --- a/path/path.go +++ b/path/path.go @@ -78,6 +78,13 @@ func (p Path) Equal(o Path) bool { return true } +// Expression returns an Expression which exactly matches the Path. +func (p Path) Expression() Expression { + return Expression{ + steps: p.steps.ExpressionSteps(), + } +} + // ParentPath returns a copy of the path with the last step removed. // // If the current path is empty, an empty path is returned. diff --git a/path/path_step.go b/path/path_step.go index 27a40e558..77d115615 100644 --- a/path/path_step.go +++ b/path/path_step.go @@ -7,6 +7,10 @@ type PathStep interface { // Equal should return true if the given PathStep is exactly equivalent. Equal(PathStep) bool + // ExpressionStep should return an ExpressionStep which exactly + // matches the PathStep. + ExpressionStep() ExpressionStep + // String should return a human-readable representation of the step // intended for logging and error messages. There should not be usage // that needs to be protected by compatibility guarantees. diff --git a/path/path_step_attribute_name.go b/path/path_step_attribute_name.go index dd4a16cb6..110ed0808 100644 --- a/path/path_step_attribute_name.go +++ b/path/path_step_attribute_name.go @@ -23,6 +23,11 @@ func (s PathStepAttributeName) Equal(o PathStep) bool { return string(s) == string(other) } +// ExpressionStep returns the ExpressionStep for the PathStep. +func (s PathStepAttributeName) ExpressionStep() ExpressionStep { + return ExpressionStepAttributeNameExact(s) +} + // String returns the human-readable representation of the attribute name. // It is intended for logging and error messages and is not protected by // compatibility guarantees. diff --git a/path/path_step_attribute_name_test.go b/path/path_step_attribute_name_test.go index 7498140a3..15ce073ff 100644 --- a/path/path_step_attribute_name_test.go +++ b/path/path_step_attribute_name_test.go @@ -58,6 +58,34 @@ func TestPathStepAttributeNameEqual(t *testing.T) { } } +func TestPathStepAttributeNameExpressionStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.PathStepAttributeName + expected path.ExpressionStep + }{ + "basic": { + step: path.PathStepAttributeName("test"), + expected: path.ExpressionStepAttributeNameExact("test"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.ExpressionStep() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestPathStepAttributeNameString(t *testing.T) { t.Parallel() diff --git a/path/path_step_element_key_int.go b/path/path_step_element_key_int.go index c4ae32fb2..3d5d884a0 100644 --- a/path/path_step_element_key_int.go +++ b/path/path_step_element_key_int.go @@ -25,6 +25,11 @@ func (s PathStepElementKeyInt) Equal(o PathStep) bool { return int64(s) == int64(other) } +// ExpressionStep returns the ExpressionStep for the PathStep. +func (s PathStepElementKeyInt) ExpressionStep() ExpressionStep { + return ExpressionStepElementKeyIntExact(s) +} + // String returns the human-readable representation of the element key. // It is intended for logging and error messages and is not protected by // compatibility guarantees. diff --git a/path/path_step_element_key_int_test.go b/path/path_step_element_key_int_test.go index 87d8aaf08..f03a74db2 100644 --- a/path/path_step_element_key_int_test.go +++ b/path/path_step_element_key_int_test.go @@ -58,6 +58,34 @@ func TestPathStepElementKeyIntEqual(t *testing.T) { } } +func TestPathStepElementKeyIntExpressionStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.PathStepElementKeyInt + expected path.ExpressionStep + }{ + "basic": { + step: path.PathStepElementKeyInt(1), + expected: path.ExpressionStepElementKeyIntExact(1), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.ExpressionStep() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestPathStepElementKeyIntString(t *testing.T) { t.Parallel() diff --git a/path/path_step_element_key_string.go b/path/path_step_element_key_string.go index 083a1bca1..b3ec8f135 100644 --- a/path/path_step_element_key_string.go +++ b/path/path_step_element_key_string.go @@ -25,6 +25,11 @@ func (s PathStepElementKeyString) Equal(o PathStep) bool { return string(s) == string(other) } +// ExpressionStep returns the ExpressionStep for the PathStep. +func (s PathStepElementKeyString) ExpressionStep() ExpressionStep { + return ExpressionStepElementKeyStringExact(s) +} + // String returns the human-readable representation of the element key. // It is intended for logging and error messages and is not protected by // compatibility guarantees. diff --git a/path/path_step_element_key_string_test.go b/path/path_step_element_key_string_test.go index fe425b6c9..a98f4de1c 100644 --- a/path/path_step_element_key_string_test.go +++ b/path/path_step_element_key_string_test.go @@ -58,6 +58,34 @@ func TestPathStepElementKeyStringEqual(t *testing.T) { } } +func TestPathStepElementKeyStringExpressionStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.PathStepElementKeyString + expected path.ExpressionStep + }{ + "basic": { + step: path.PathStepElementKeyString("test"), + expected: path.ExpressionStepElementKeyStringExact("test"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.ExpressionStep() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestPathStepElementKeyStringString(t *testing.T) { t.Parallel() diff --git a/path/path_step_element_key_value.go b/path/path_step_element_key_value.go index 9d3fc3815..535fddbd2 100644 --- a/path/path_step_element_key_value.go +++ b/path/path_step_element_key_value.go @@ -32,6 +32,11 @@ func (s PathStepElementKeyValue) Equal(o PathStep) bool { return s.Value.Equal(other.Value) } +// ExpressionStep returns the ExpressionStep for the PathStep. +func (s PathStepElementKeyValue) ExpressionStep() ExpressionStep { + return ExpressionStepElementKeyValueExact(s) +} + // String returns the human-readable representation of the element key. // It is intended for logging and error messages and is not protected by // compatibility guarantees. diff --git a/path/path_step_element_key_value_test.go b/path/path_step_element_key_value_test.go index 035b44734..6be90b128 100644 --- a/path/path_step_element_key_value_test.go +++ b/path/path_step_element_key_value_test.go @@ -64,6 +64,34 @@ func TestPathStepElementKeyValueEqual(t *testing.T) { } } +func TestPathStepElementKeyValueExpressionStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + step path.PathStepElementKeyValue + expected path.ExpressionStep + }{ + "basic": { + step: path.PathStepElementKeyValue{Value: types.String{Value: "test"}}, + expected: path.ExpressionStepElementKeyValueExact{Value: types.String{Value: "test"}}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.step.ExpressionStep() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestPathStepElementKeyValueString(t *testing.T) { t.Parallel() diff --git a/path/path_steps.go b/path/path_steps.go index 1f6a6efe5..43edf30d2 100644 --- a/path/path_steps.go +++ b/path/path_steps.go @@ -84,3 +84,15 @@ func (s PathSteps) String() string { return result.String() } + +// ExpressionSteps returns the ordered collection of expression steps which +// exactly matches the PathSteps. +func (s PathSteps) ExpressionSteps() ExpressionSteps { + result := make(ExpressionSteps, len(s)) + + for stepIndex, pathStep := range s { + result[stepIndex] = pathStep.ExpressionStep() + } + + return result +} diff --git a/path/path_steps_test.go b/path/path_steps_test.go index 8052bde58..dbff0f134 100644 --- a/path/path_steps_test.go +++ b/path/path_steps_test.go @@ -311,6 +311,56 @@ func TestPathStepsEqual(t *testing.T) { } } +func TestPathStepsExpressionSteps(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + steps path.PathSteps + expected path.ExpressionSteps + }{ + "nil": { + steps: nil, + expected: path.ExpressionSteps{}, + }, + "empty": { + steps: path.PathSteps{}, + expected: path.ExpressionSteps{}, + }, + "one": { + steps: path.PathSteps{ + path.PathStepAttributeName("test"), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + }, + }, + "two": { + steps: path.PathSteps{ + path.PathStepAttributeName("test"), + path.PathStepElementKeyInt(1), + }, + expected: path.ExpressionSteps{ + path.ExpressionStepAttributeNameExact("test"), + path.ExpressionStepElementKeyIntExact(1), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.steps.ExpressionSteps() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestPathStepsLastStep(t *testing.T) { t.Parallel() diff --git a/path/path_test.go b/path/path_test.go index 334249290..711fb23d0 100644 --- a/path/path_test.go +++ b/path/path_test.go @@ -260,6 +260,38 @@ func TestPathEqual(t *testing.T) { } } +func TestPathExpression(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + path path.Path + expected path.Expression + }{ + "one": { + path: path.Root("test"), + expected: path.MatchRoot("test"), + }, + "two": { + path: path.Root("test").AtListIndex(1), + expected: path.MatchRoot("test").AtListIndex(1), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.path.Expression() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestPathParentPath(t *testing.T) { t.Parallel() diff --git a/tfsdk/attribute_plan_modification.go b/tfsdk/attribute_plan_modification.go index 33173de6a..d4f0fd8ec 100644 --- a/tfsdk/attribute_plan_modification.go +++ b/tfsdk/attribute_plan_modification.go @@ -389,9 +389,14 @@ func (r UseStateForUnknownModifier) MarkdownDescription(ctx context.Context) str // instance of this request struct is supplied as an argument to the Modify // function of an attribute's plan modifier(s). type ModifyAttributePlanRequest struct { - // AttributePath is the path of the attribute. + // AttributePath is the path of the attribute. Use this path for any + // response diagnostics. AttributePath path.Path + // AttributePathExpression is the expression matching the exact path of the + // attribute. + AttributePathExpression path.Expression + // Config is the configuration the user supplied for the resource. Config Config diff --git a/tfsdk/attribute_validation.go b/tfsdk/attribute_validation.go index 3d73a71a1..c9a6a6167 100644 --- a/tfsdk/attribute_validation.go +++ b/tfsdk/attribute_validation.go @@ -26,11 +26,16 @@ type AttributeValidator interface { Validate(context.Context, ValidateAttributeRequest, *ValidateAttributeResponse) } -// ValidateAttributeRequest repesents a request for +// ValidateAttributeRequest repesents a request for attribute validation. type ValidateAttributeRequest struct { - // AttributePath contains the path of the attribute. + // AttributePath contains the path of the attribute. Use this path for any + // response diagnostics. AttributePath path.Path + // AttributePathExpression contains the expression matching the exact path + // of the attribute. + AttributePathExpression path.Expression + // AttributeConfig contains the value of the attribute in the configuration. AttributeConfig attr.Value diff --git a/tfsdk/config.go b/tfsdk/config.go index 354e72a61..76127e37e 100644 --- a/tfsdk/config.go +++ b/tfsdk/config.go @@ -59,6 +59,11 @@ func (c Config) GetAttribute(ctx context.Context, path path.Path, target interfa return diags } +// PathMatches returns all matching path.Paths from the given path.Expression. +func (c Config) PathMatches(ctx context.Context, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { + return pathMatches(ctx, c.Schema, c.Raw, pathExpr) +} + // getAttributeValue retrieves the attribute found at `path` and returns it as an // attr.Value. Consumers should assert the type of the returned value with the // desired attr.Type. diff --git a/tfsdk/config_test.go b/tfsdk/config_test.go index 330568e97..1be8e48c2 100644 --- a/tfsdk/config_test.go +++ b/tfsdk/config_test.go @@ -1735,3 +1735,84 @@ func TestConfigGetAttributeValue(t *testing.T) { }) } } + +func TestConfigPathMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + config Config + expression path.Expression + expected path.Paths + expectedDiags diag.Diagnostics + }{ + // Refer to TestPathMatches for more exhaustive unit testing. + // These test cases are to ensure Config schema and data values are + // passed appropriately to the shared implementation. + "AttributeNameExact-match": { + config: Config{ + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expression: path.MatchRoot("test"), + expected: path.Paths{ + path.Root("test"), + }, + }, + "AttributeNameExact-mismatch": { + config: Config{ + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expression: path.MatchRoot("not-test"), + expected: nil, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := testCase.config.PathMatches(context.Background(), testCase.expression) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/tfsdk/path_matches.go b/tfsdk/path_matches.go new file mode 100644 index 000000000..1aead5c73 --- /dev/null +++ b/tfsdk/path_matches.go @@ -0,0 +1,40 @@ +package tfsdk + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// pathMatches returns all matching path.Paths from the given path.Expression. +// +// TODO: This function should be part of a internal/schemadata package +// except that doing so would currently introduce an import cycle due to the +// Schema parameter here and Config/Plan/State.PathMatches needing to +// call this function until the schema data is migrated to attr.Value. +// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/172 +// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/365 +func pathMatches(ctx context.Context, schema Schema, tfTypeValue tftypes.Value, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { + var diags diag.Diagnostics + var paths path.Paths + + _ = tftypes.Walk(tfTypeValue, func(tfTypePath *tftypes.AttributePath, tfTypeValue tftypes.Value) (bool, error) { + fwPath, fwPathDiags := attributePath(ctx, tfTypePath, schema) + + diags.Append(fwPathDiags...) + + if diags.HasError() { + return false, nil + } + + if pathExpr.Matches(fwPath) { + paths = append(paths, fwPath) + } + + return true, nil + }) + + return paths, diags +} diff --git a/tfsdk/path_matches_test.go b/tfsdk/path_matches_test.go new file mode 100644 index 000000000..27d416ae2 --- /dev/null +++ b/tfsdk/path_matches_test.go @@ -0,0 +1,627 @@ +package tfsdk + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestPathMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schema Schema + tfTypeValue tftypes.Value + expression path.Expression + expected path.Paths + expectedDiags diag.Diagnostics + }{ + "AttributeNameExact-match": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + expression: path.MatchRoot("test"), + expected: path.Paths{ + path.Root("test"), + }, + }, + "AttributeNameExact-mismatch": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + expression: path.MatchRoot("not-test"), + expected: nil, + }, + "AttributeNameExact-ElementKeyIntAny-match": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.ListType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.List{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtAnyListIndex(), + expected: path.Paths{ + path.Root("test").AtListIndex(0), + path.Root("test").AtListIndex(1), + path.Root("test").AtListIndex(2), + }, + }, + "AttributeNameExact-ElementKeyIntAny-mismatch": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Set{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtAnyListIndex(), + expected: nil, + }, + "AttributeNameExact-ElementKeyIntExact-match": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.ListType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.List{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtListIndex(1), + expected: path.Paths{ + path.Root("test").AtListIndex(1), + }, + }, + "AttributeNameExact-ElementKeyIntExact-AttributeNameExact-Parent-AttributeNameExact-match": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test_parent": { + Attributes: ListNestedAttributes(map[string]Attribute{ + "test_child1": { + Type: types.StringType, + }, + "test_child2": { + Type: types.StringType, + }, + }), + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_parent": tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_child1": tftypes.String, + "test_child2": tftypes.String, + }, + }, + }, + }, + }, + map[string]tftypes.Value{ + "test_parent": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_child1": tftypes.String, + "test_child2": tftypes.String, + }, + }, + }, + []tftypes.Value{ + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_child1": tftypes.String, + "test_child2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_child1": tftypes.NewValue(tftypes.String, "test-value-list-0-child-1"), + "test_child2": tftypes.NewValue(tftypes.String, "test-value-list-0-child-2"), + }, + ), + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_child1": tftypes.String, + "test_child2": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_child1": tftypes.NewValue(tftypes.String, "test-value-list-1-child-1"), + "test_child2": tftypes.NewValue(tftypes.String, "test-value-list-1-child-2"), + }, + ), + }, + ), + }, + ), + // e.g. Something that would be created in an attribute plan modifier or validator + expression: path.MatchRoot("test_parent").AtListIndex(1).AtName("test_child1").AtParent().AtName("test_child2"), + expected: path.Paths{ + path.Root("test_parent").AtListIndex(1).AtName("test_child2"), + }, + }, + "AttributeNameExact-ElementKeyIntExact-mismatch": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.ListType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.List{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtListIndex(4), + expected: nil, + }, + "AttributeNameExact-ElementKeyStringAny-match": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.MapType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Map{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.Map{ + ElementType: tftypes.String, + }, + map[string]tftypes.Value{ + // Map access is non-deterministic, so test with + // a single key to prevent ordering issues in + // the expected path.Paths + "test-key1": tftypes.NewValue(tftypes.String, "test-value1"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtAnyMapKey(), + expected: path.Paths{ + path.Root("test").AtMapKey("test-key1"), + }, + }, + "AttributeNameExact-ElementKeyStringAny-mismatch": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.ListType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.List{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtAnyMapKey(), + expected: nil, + }, + "AttributeNameExact-ElementKeyStringExact-match": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.MapType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Map{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.Map{ + ElementType: tftypes.String, + }, + map[string]tftypes.Value{ + "test-key1": tftypes.NewValue(tftypes.String, "test-value1"), + "test-key2": tftypes.NewValue(tftypes.String, "test-value2"), + "test-key3": tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtMapKey("test-key2"), + expected: path.Paths{ + path.Root("test").AtMapKey("test-key2"), + }, + }, + "AttributeNameExact-ElementKeyStringExact-mismatch": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.MapType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Map{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.Map{ + ElementType: tftypes.String, + }, + map[string]tftypes.Value{ + "test-key1": tftypes.NewValue(tftypes.String, "test-value1"), + "test-key2": tftypes.NewValue(tftypes.String, "test-value2"), + "test-key3": tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtMapKey("test-key4"), + expected: nil, + }, + "AttributeNameExact-ElementKeyValueAny-match": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Set{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtAnySetValue(), + expected: path.Paths{ + path.Root("test").AtSetValue(types.String{Value: "test-value1"}), + path.Root("test").AtSetValue(types.String{Value: "test-value2"}), + path.Root("test").AtSetValue(types.String{Value: "test-value3"}), + }, + }, + "AttributeNameExact-ElementKeyValueAny-mismatch": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.ListType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.List{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtAnySetValue(), + expected: nil, + }, + "AttributeNameExact-ElementKeyValueExact-match": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Set{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtSetValue(types.String{Value: "test-value2"}), + expected: path.Paths{ + path.Root("test").AtSetValue(types.String{Value: "test-value2"}), + }, + }, + "AttributeNameExact-ElementKeyValueExact-mismatch": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Set{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtSetValue(types.String{Value: "test-value4"}), + expected: nil, + }, + "AttributeNameExact-Parent": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + expression: path.MatchRoot("test").AtParent(), + expected: nil, + }, + "AttributeNameExact-Parent-Parent": { + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + expression: path.MatchRoot("test").AtParent().AtParent(), + expected: nil, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := pathMatches(context.Background(), testCase.schema, testCase.tfTypeValue, testCase.expression) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/tfsdk/plan.go b/tfsdk/plan.go index 1b6f5e930..ab64b9130 100644 --- a/tfsdk/plan.go +++ b/tfsdk/plan.go @@ -130,6 +130,11 @@ func (p Plan) getAttributeValue(ctx context.Context, path path.Path) (attr.Value return attrValue, diags } +// PathMatches returns all matching path.Paths from the given path.Expression. +func (p Plan) PathMatches(ctx context.Context, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { + return pathMatches(ctx, p.Schema, p.Raw, pathExpr) +} + // Set populates the entire plan using the supplied Go value. The value `val` // should be a struct whose values have one of the attr.Value types. Each field // must be tagged with the corresponding schema field. diff --git a/tfsdk/plan_test.go b/tfsdk/plan_test.go index 481fd44f6..4a9304d6b 100644 --- a/tfsdk/plan_test.go +++ b/tfsdk/plan_test.go @@ -2167,6 +2167,87 @@ func TestPlanPathExists(t *testing.T) { } } +func TestPlanPathMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + plan Plan + expression path.Expression + expected path.Paths + expectedDiags diag.Diagnostics + }{ + // Refer to TestPathMatches for more exhaustive unit testing. + // These test cases are to ensure Plan schema and data values are + // passed appropriately to the shared implementation. + "AttributeNameExact-match": { + plan: Plan{ + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expression: path.MatchRoot("test"), + expected: path.Paths{ + path.Root("test"), + }, + }, + "AttributeNameExact-mismatch": { + plan: Plan{ + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expression: path.MatchRoot("not-test"), + expected: nil, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := testCase.plan.PathMatches(context.Background(), testCase.expression) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} + func TestPlanSet(t *testing.T) { t.Parallel() diff --git a/tfsdk/state.go b/tfsdk/state.go index 773e0a872..da20c1231 100644 --- a/tfsdk/state.go +++ b/tfsdk/state.go @@ -130,6 +130,11 @@ func (s State) getAttributeValue(ctx context.Context, path path.Path) (attr.Valu return attrValue, diags } +// PathMatches returns all matching path.Paths from the given path.Expression. +func (s State) PathMatches(ctx context.Context, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { + return pathMatches(ctx, s.Schema, s.Raw, pathExpr) +} + // Set populates the entire state using the supplied Go value. The value `val` // should be a struct whose values have one of the attr.Value types. Each field // must be tagged with the corresponding schema field. diff --git a/tfsdk/state_test.go b/tfsdk/state_test.go index 72ae041d2..f417cac84 100644 --- a/tfsdk/state_test.go +++ b/tfsdk/state_test.go @@ -2864,6 +2864,87 @@ func TestStatePathExists(t *testing.T) { } } +func TestStatePathMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + state State + expression path.Expression + expected path.Paths + expectedDiags diag.Diagnostics + }{ + // Refer to TestPathMatches for more exhaustive unit testing. + // These test cases are to ensure State schema and data values are + // passed appropriately to the shared implementation. + "AttributeNameExact-match": { + state: State{ + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expression: path.MatchRoot("test"), + expected: path.Paths{ + path.Root("test"), + }, + }, + "AttributeNameExact-mismatch": { + state: State{ + Schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expression: path.MatchRoot("not-test"), + expected: nil, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := testCase.state.PathMatches(context.Background(), testCase.expression) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} + func TestStateSet(t *testing.T) { t.Parallel() diff --git a/tfsdk/tftypes_attribute_path.go b/tfsdk/tftypes_attribute_path.go new file mode 100644 index 000000000..70aac6a0a --- /dev/null +++ b/tfsdk/tftypes_attribute_path.go @@ -0,0 +1,95 @@ +package tfsdk + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromtftypes" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// attributePath returns the path.Path equivalent of a *tftypes.AttributePath. +// +// TODO: This function should be exported as internal/fromtftypes.AttributePath +// except that doing so would currently introduce an import cycle due to the +// tfsdk.Schema parameter here and Config/Plan/State.PathMatches needing to +// call this function until the schema data is migrated to attr.Value. +// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/172 +// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/365 +func attributePath(ctx context.Context, tfType *tftypes.AttributePath, schema Schema) (path.Path, diag.Diagnostics) { + fwPath := path.Empty() + + for tfTypeStepIndex, tfTypeStep := range tfType.Steps() { + currentTfTypeSteps := tfType.Steps()[:tfTypeStepIndex+1] + currentTfTypePath := tftypes.NewAttributePathWithSteps(currentTfTypeSteps) + attrType, err := schema.AttributeTypeAtPath(currentTfTypePath) + + if err != nil { + return path.Empty(), diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Attribute Path", + "An unexpected error occurred while trying to convert an attribute path. "+ + "This is an error in terraform-plugin-framework used by the provider. "+ + "Please report the following to the provider developers.\n\n"+ + // Since this is an error with the attribute path + // conversion, we cannot return a protocol path-based + // diagnostic. Returning a framework human-readable + // representation seems like the next best thing to do. + fmt.Sprintf("Attribute Path: %s\n", currentTfTypePath.String())+ + fmt.Sprintf("Original Error: %s", err), + ), + } + } + + fwStep, err := fromtftypes.AttributePathStep(ctx, tfTypeStep, attrType) + + if err != nil { + return path.Empty(), diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Attribute Path", + "An unexpected error occurred while trying to convert an attribute path. "+ + "This is either an error in terraform-plugin-framework or a custom attribute type used by the provider. "+ + "Please report the following to the provider developers.\n\n"+ + // Since this is an error with the attribute path + // conversion, we cannot return a protocol path-based + // diagnostic. Returning a framework human-readable + // representation seems like the next best thing to do. + fmt.Sprintf("Attribute Path: %s\n", currentTfTypePath.String())+ + fmt.Sprintf("Original Error: %s", err), + ), + } + } + + // In lieu of creating a path.NewPathFromSteps function, this path + // building logic is inlined to not expand the path package API. + switch fwStep := fwStep.(type) { + case path.PathStepAttributeName: + fwPath = fwPath.AtName(string(fwStep)) + case path.PathStepElementKeyInt: + fwPath = fwPath.AtListIndex(int(fwStep)) + case path.PathStepElementKeyString: + fwPath = fwPath.AtMapKey(string(fwStep)) + case path.PathStepElementKeyValue: + fwPath = fwPath.AtSetValue(fwStep.Value) + default: + return fwPath, diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Attribute Path", + "An unexpected error occurred while trying to convert an attribute path. "+ + "This is an error in terraform-plugin-framework used by the provider. "+ + "Please report the following to the provider developers.\n\n"+ + // Since this is an error with the attribute path + // conversion, we cannot return a protocol path-based + // diagnostic. Returning a framework human-readable + // representation seems like the next best thing to do. + fmt.Sprintf("Attribute Path: %s\n", currentTfTypePath.String())+ + fmt.Sprintf("Original Error: unknown path.PathStep type: %#v", fwStep), + ), + } + } + } + + return fwPath, nil +} diff --git a/tfsdk/tftypes_attribute_path_test.go b/tfsdk/tftypes_attribute_path_test.go new file mode 100644 index 000000000..bb6eb826c --- /dev/null +++ b/tfsdk/tftypes_attribute_path_test.go @@ -0,0 +1,198 @@ +package tfsdk + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + testtypes "github.com/hashicorp/terraform-plugin-framework/internal/testing/types" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestAttributePath(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + tfType *tftypes.AttributePath + schema Schema + expected path.Path + expectedDiags diag.Diagnostics + }{ + "nil": { + tfType: nil, + expected: path.Empty(), + }, + "empty": { + tfType: tftypes.NewAttributePath(), + expected: path.Empty(), + }, + "AttributeName": { + tfType: tftypes.NewAttributePath().WithAttributeName("test"), + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.StringType, + }, + }, + }, + expected: path.Root("test"), + }, + "AttributeName-nonexistent-attribute": { + tfType: tftypes.NewAttributePath().WithAttributeName("test"), + schema: Schema{ + Attributes: map[string]Attribute{ + "not-test": { + Type: testtypes.StringType{}, + }, + }, + }, + expected: path.Empty(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Attribute Path", + "An unexpected error occurred while trying to convert an attribute path. "+ + "This is an error in terraform-plugin-framework used by the provider. "+ + "Please report the following to the provider developers.\n\n"+ + "Attribute Path: AttributeName(\"test\")\n"+ + "Original Error: AttributeName(\"test\") still remains in the path: could not find attribute or block \"test\" in schema", + ), + }, + }, + "AttributeName-ElementKeyInt": { + tfType: tftypes.NewAttributePath().WithAttributeName("test").WithElementKeyInt(1), + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.ListType{ + ElemType: types.StringType, + }, + }, + }, + }, + expected: path.Root("test").AtListIndex(1), + }, + "AttributeName-ElementKeyValue": { + tfType: tftypes.NewAttributePath().WithAttributeName("test").WithElementKeyValue(tftypes.NewValue(tftypes.String, "test-value")), + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + expected: path.Root("test").AtSetValue(types.String{Value: "test-value"}), + }, + "AttributeName-ElementKeyValue-value-conversion-error": { + tfType: tftypes.NewAttributePath().WithAttributeName("test").WithElementKeyValue(tftypes.NewValue(tftypes.String, "test-value")), + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: types.SetType{ + ElemType: testtypes.InvalidType{}, + }, + }, + }, + }, + expected: path.Empty(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Attribute Path", + "An unexpected error occurred while trying to convert an attribute path. "+ + "This is either an error in terraform-plugin-framework or a custom attribute type used by the provider. "+ + "Please report the following to the provider developers.\n\n"+ + "Attribute Path: AttributeName(\"test\").ElementKeyValue(tftypes.String<\"test-value\">)\n"+ + "Original Error: unable to create PathStepElementKeyValue from tftypes.Value: unable to convert tftypes.Value (tftypes.String<\"test-value\">) to attr.Value: intentional ValueFromTerraform error", + ), + }, + }, + "ElementKeyInt": { + tfType: tftypes.NewAttributePath().WithElementKeyInt(1), + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: testtypes.StringType{}, + }, + }, + }, + expected: path.Empty(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Attribute Path", + "An unexpected error occurred while trying to convert an attribute path. "+ + "This is an error in terraform-plugin-framework used by the provider. "+ + "Please report the following to the provider developers.\n\n"+ + "Attribute Path: ElementKeyInt(1)\n"+ + "Original Error: ElementKeyInt(1) still remains in the path: cannot apply AttributePathStep tftypes.ElementKeyInt to schema", + ), + }, + }, + "ElementKeyString": { + tfType: tftypes.NewAttributePath().WithElementKeyString("test"), + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: testtypes.StringType{}, + }, + }, + }, + expected: path.Empty(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Attribute Path", + "An unexpected error occurred while trying to convert an attribute path. "+ + "This is an error in terraform-plugin-framework used by the provider. "+ + "Please report the following to the provider developers.\n\n"+ + "Attribute Path: ElementKeyString(\"test\")\n"+ + "Original Error: ElementKeyString(\"test\") still remains in the path: cannot apply AttributePathStep tftypes.ElementKeyString to schema", + ), + }, + }, + "ElementKeyValue": { + tfType: tftypes.NewAttributePath().WithElementKeyValue(tftypes.NewValue(tftypes.String, "test-value")), + schema: Schema{ + Attributes: map[string]Attribute{ + "test": { + Type: testtypes.StringType{}, + }, + }, + }, + expected: path.Empty(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Attribute Path", + "An unexpected error occurred while trying to convert an attribute path. "+ + "This is an error in terraform-plugin-framework used by the provider. "+ + "Please report the following to the provider developers.\n\n"+ + "Attribute Path: ElementKeyValue(tftypes.String<\"test-value\">)\n"+ + "Original Error: ElementKeyValue(tftypes.String<\"test-value\">) still remains in the path: cannot apply AttributePathStep tftypes.ElementKeyValue to schema", + ), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := attributePath(context.Background(), testCase.tfType, testCase.schema) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + for _, d := range diags { + t.Logf("diag: %s", d.Detail()) + } + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +}