Add support for lists in the structured renderer (#32401)

* prep for processing the structured run output

* undo unwanted change to a json key

* Add skeleton functions and API for refactored renderer

* goimports

* Fix documentation of the RenderOpts struct

* Add rendering functionality for primitives to the structured renderer

* add test case for override

* Add support for parsing and rendering sensitive values in the renderer

* Add support for unknown/computed values in the structured renderer

* delete missing unit tests

* Add support for object attributes in the structured renderer

* goimports

* Add support for the replace paths data in the structured renderer

* Add support for maps in the structured renderer

* Add support for lists in the structured renderer

* goimports

* add additional comments explaining
This commit is contained in:
Liam Cervante 2023-01-09 14:06:38 +01:00 committed by GitHub
parent 8975eebf84
commit aff94591c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 998 additions and 13 deletions

View File

@ -0,0 +1,120 @@
package change
import (
"bytes"
"fmt"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/plans"
)
func List(elements []Change) Renderer {
return &listRenderer{
displayContext: true,
elements: elements,
}
}
func NestedList(elements []Change) Renderer {
return &listRenderer{
elements: elements,
}
}
type listRenderer struct {
NoWarningsRenderer
// displayContext tells the renderer to display additional information about
// the before and after index values within a given list. For example, index
//
displayContext bool
elements []Change
}
func (renderer listRenderer) Render(change Change, indent int, opts RenderOpts) string {
if len(renderer.elements) == 0 {
return fmt.Sprintf("[]%s%s", change.nullSuffix(opts.overrideNullSuffix), change.forcesReplacement())
}
elementOpts := opts.Clone()
elementOpts.overrideNullSuffix = true
unchangedElementOpts := opts.Clone()
unchangedElementOpts.showUnchangedChildren = true
var unchangedElements []Change
// renderNext tells the renderer to print out the next element in the list
// whatever state it is in. So, even if a change is a NoOp we will still
// print it out if the last change we processed wants us to.
renderNext := false
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("[%s\n", change.forcesReplacement()))
for _, element := range renderer.elements {
if element.action == plans.NoOp && !renderNext && !opts.showUnchangedChildren {
unchangedElements = append(unchangedElements, element)
continue
}
renderNext = false
// If we want to display the context around this change, we want to
// render the change immediately before this change in the list, and the
// change immediately after in the list, even if both these changes are
// NoOps. This will give the user reading the diff some context as to
// where in the list these changes are being made, as order matters.
if renderer.displayContext {
// If our list of unchanged elements contains more than one entry
// we'll print out a count of the number of unchanged elements that
// we skipped. Note, this is the length of the unchanged elements
// minus 1 as the most recent unchanged element will be printed out
// in full.
if len(unchangedElements) > 1 {
buf.WriteString(fmt.Sprintf("%s%s %s\n", change.indent(indent+1), change.emptySymbol(), change.unchanged("element", len(unchangedElements)-1)))
}
// If our list of unchanged elements contains at least one entry,
// we're going to print out the most recent change in full. That's
// what happens here.
if len(unchangedElements) > 0 {
lastElement := unchangedElements[len(unchangedElements)-1]
buf.WriteString(fmt.Sprintf("%s %s,\n", change.indent(indent+1), lastElement.Render(indent+1, unchangedElementOpts)))
}
// We now reset the unchanged elements list, we've printed out a
// count of all the elements we skipped so we start counting from
// scratch again. This means that if we process a run of changed
// elements, they won't all start printing out summaries of every
// change that happened previously.
unchangedElements = nil
// As we also want to render the element immediately after any
// changes, we make a note here to say we should render the next
// change whatever it is. But, we only want to render the next
// change if the current change isn't a NoOp. If the current change
// is a NoOp then it was told to print by the last change and we
// don't want to cascade and print all changes from now on.
renderNext = element.action != plans.NoOp
}
for _, warning := range element.Warnings(indent + 1) {
buf.WriteString(fmt.Sprintf("%s%s\n", change.indent(indent+1), warning))
}
if element.action == plans.NoOp {
buf.WriteString(fmt.Sprintf("%s %s,\n", change.indent(indent+1), element.Render(indent+1, unchangedElementOpts)))
} else {
buf.WriteString(fmt.Sprintf("%s%s %s,\n", change.indent(indent+1), format.DiffActionSymbol(element.action), element.Render(indent+1, elementOpts)))
}
}
// If we were not displaying any context alongside our changes then the
// unchangedElements list will contain every unchanged element, and we'll
// print that out as we do with every other collection.
//
// If we were displaying context, then this will contain any unchanged
// elements since our last change, so we should also print it out.
if len(unchangedElements) > 0 {
buf.WriteString(fmt.Sprintf("%s%s %s\n", change.indent(indent+1), change.emptySymbol(), change.unchanged("element", len(unchangedElements))))
}
buf.WriteString(fmt.Sprintf("%s%s ]%s", change.indent(indent), change.emptySymbol(), change.nullSuffix(opts.overrideNullSuffix)))
return buf.String()
}

View File

@ -518,6 +518,24 @@ func TestRenderers(t *testing.T) {
{
~ "element_one" = (sensitive)
}
`,
},
"map_update_sensitive_element_status": {
change: Change{
renderer: Map(map[string]Change{
"element_one": {
renderer: Sensitive(0, 0, true, false),
action: plans.Update,
},
}),
action: plans.Update,
},
expected: `
{
# Warning: this attribute value will no longer be marked as sensitive
# after applying this change. The value is unchanged.
~ "element_one" = (sensitive)
}
`,
},
"map_delete_sensitive_element": {
@ -569,6 +587,308 @@ func TestRenderers(t *testing.T) {
{
~ "element_one" = 1 -> (known after apply)
}
`,
},
"list_create_empty": {
change: Change{
renderer: List([]Change{}),
action: plans.Create,
},
expected: "[]",
},
"list_create": {
change: Change{
renderer: List([]Change{
{
renderer: Primitive(nil, strptr("1")),
action: plans.Create,
},
}),
action: plans.Create,
},
expected: `
[
+ 1,
]
`,
},
"list_delete_empty": {
change: Change{
renderer: List([]Change{}),
action: plans.Delete,
},
expected: "[] -> null",
},
"list_delete": {
change: Change{
renderer: List([]Change{
{
renderer: Primitive(strptr("1"), nil),
action: plans.Delete,
},
}),
action: plans.Delete,
},
expected: `
[
- 1,
] -> null
`,
},
"list_create_element": {
change: Change{
renderer: List([]Change{
{
renderer: Primitive(nil, strptr("1")),
action: plans.Create,
},
}),
action: plans.Update,
},
expected: `
[
+ 1,
]
`,
},
"list_update_element": {
change: Change{
renderer: List([]Change{
{
renderer: Primitive(strptr("0"), strptr("1")),
action: plans.Update,
},
}),
action: plans.Update,
},
expected: `
[
~ 0 -> 1,
]
`,
},
"list_replace_element": {
change: Change{
renderer: List([]Change{
{
renderer: Primitive(strptr("0"), nil),
action: plans.Delete,
},
{
renderer: Primitive(nil, strptr("1")),
action: plans.Create,
},
}),
action: plans.Update,
},
expected: `
[
- 0,
+ 1,
]
`,
},
"list_delete_element": {
change: Change{
renderer: List([]Change{
{
renderer: Primitive(strptr("0"), nil),
action: plans.Delete,
},
}),
action: plans.Update,
},
expected: `
[
- 0,
]
`,
},
"list_update_forces_replacement": {
change: Change{
renderer: List([]Change{
{
renderer: Primitive(strptr("0"), strptr("1")),
action: plans.Update,
},
}),
action: plans.Update,
replace: true,
},
expected: `
[ # forces replacement
~ 0 -> 1,
]
`,
},
"list_update_ignores_unchanged": {
change: Change{
renderer: NestedList([]Change{
{
renderer: Primitive(strptr("0"), strptr("0")),
action: plans.NoOp,
},
{
renderer: Primitive(strptr("1"), strptr("1")),
action: plans.NoOp,
},
{
renderer: Primitive(strptr("2"), strptr("5")),
action: plans.Update,
},
{
renderer: Primitive(strptr("3"), strptr("3")),
action: plans.NoOp,
},
{
renderer: Primitive(strptr("4"), strptr("4")),
action: plans.NoOp,
},
}),
action: plans.Update,
},
expected: `
[
~ 2 -> 5,
# (4 unchanged elements hidden)
]
`,
},
"list_update_ignored_unchanged_with_context": {
change: Change{
renderer: List([]Change{
{
renderer: Primitive(strptr("0"), strptr("0")),
action: plans.NoOp,
},
{
renderer: Primitive(strptr("1"), strptr("1")),
action: plans.NoOp,
},
{
renderer: Primitive(strptr("2"), strptr("5")),
action: plans.Update,
},
{
renderer: Primitive(strptr("3"), strptr("3")),
action: plans.NoOp,
},
{
renderer: Primitive(strptr("4"), strptr("4")),
action: plans.NoOp,
},
}),
action: plans.Update,
},
expected: `
[
# (1 unchanged element hidden)
1,
~ 2 -> 5,
3,
# (1 unchanged element hidden)
]
`,
},
"list_create_sensitive_element": {
change: Change{
renderer: List([]Change{
{
renderer: Sensitive(nil, 1, false, true),
action: plans.Create,
},
}),
action: plans.Update,
},
expected: `
[
+ (sensitive),
]
`,
},
"list_delete_sensitive_element": {
change: Change{
renderer: List([]Change{
{
renderer: Sensitive(1, nil, true, false),
action: plans.Delete,
},
}),
action: plans.Update,
},
expected: `
[
- (sensitive),
]
`,
},
"list_update_sensitive_element": {
change: Change{
renderer: List([]Change{
{
renderer: Sensitive(nil, 1, false, true),
action: plans.Update,
},
}),
action: plans.Update,
},
expected: `
[
~ (sensitive),
]
`,
},
"list_update_sensitive_element_status": {
change: Change{
renderer: List([]Change{
{
renderer: Sensitive(1, 1, false, true),
action: plans.Update,
},
}),
action: plans.Update,
},
expected: `
[
# Warning: this attribute value will be marked as sensitive and will not
# display in UI output after applying this change. The value is unchanged.
~ (sensitive),
]
`,
},
"list_create_computed_element": {
change: Change{
renderer: List([]Change{
{
renderer: Computed(Change{}),
action: plans.Create,
},
}),
action: plans.Update,
},
expected: `
[
+ (known after apply),
]
`,
},
"list_update_computed_element": {
change: Change{
renderer: List([]Change{
{
renderer: Computed(Change{
renderer: Primitive(strptr("0"), nil),
action: plans.Delete,
}),
action: plans.Update,
},
}),
action: plans.Update,
},
expected: `
[
~ 0 -> (known after apply),
]
`,
},
}

View File

@ -126,6 +126,50 @@ func ValidateMap(elements map[string]ValidateChangeFunc, action plans.Action, re
}
}
func ValidateList(elements []ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc {
return func(t *testing.T, change Change) {
validateChange(t, change, action, replace)
list, ok := change.renderer.(*listRenderer)
if !ok {
t.Fatalf("invalid renderer type: %T", change.renderer)
}
if !list.displayContext {
t.Fatalf("created the wrong type of list renderer")
}
validateList(t, list, elements)
}
}
func ValidateNestedList(elements []ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc {
return func(t *testing.T, change Change) {
validateChange(t, change, action, replace)
list, ok := change.renderer.(*listRenderer)
if !ok {
t.Fatalf("invalid renderer type: %T", change.renderer)
}
if list.displayContext {
t.Fatalf("created the wrong type of list renderer")
}
validateList(t, list, elements)
}
}
func validateList(t *testing.T, list *listRenderer, elements []ValidateChangeFunc) {
if len(list.elements) != len(elements) {
t.Fatalf("expected %d elements but found %d elements", len(elements), len(list.elements))
}
for ix := 0; ix < len(elements); ix++ {
elements[ix](t, list.elements[ix])
}
}
func ValidateSensitive(before, after interface{}, beforeSensitive, afterSensitive bool, action plans.Action, replace bool) ValidateChangeFunc {
return func(t *testing.T, change Change) {
validateChange(t, change, action, replace)

View File

@ -21,6 +21,8 @@ func (v Value) computeChangeForNestedAttribute(attribute *jsonprovider.NestedTyp
return v.computeAttributeChangeAsNestedObject(attribute.Attributes)
case "map":
return v.computeAttributeChangeAsNestedMap(attribute.Attributes)
case "list":
return v.computeAttributeChangeAsNestedList(attribute.Attributes)
default:
panic("unrecognized nesting mode: " + attribute.NestingMode)
}
@ -34,8 +36,10 @@ func (v Value) computeChangeForType(ctyType cty.Type) change.Change {
return v.computeAttributeChangeAsObject(ctyType.AttributeTypes())
case ctyType.IsMapType():
return v.computeAttributeChangeAsMap(ctyType.ElementType())
case ctyType.IsListType():
return v.computeAttributeChangeAsList(ctyType.ElementType())
default:
panic("not implemented")
panic("unrecognized type: " + ctyType.FriendlyName())
}
}

View File

@ -0,0 +1,165 @@
package differ
import (
"reflect"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/command/jsonformat/change"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
)
func (v Value) computeAttributeChangeAsList(elementType cty.Type) change.Change {
var elements []change.Change
current := v.getDefaultActionForIteration()
v.processList(elementType, func(value Value) {
element := value.ComputeChange(elementType)
elements = append(elements, element)
current = compareActions(current, element.GetAction())
})
return change.New(change.List(elements), current, v.replacePath())
}
func (v Value) computeAttributeChangeAsNestedList(attributes map[string]*jsonprovider.Attribute) change.Change {
var elements []change.Change
current := v.getDefaultActionForIteration()
v.processNestedList(func(value Value) {
element := value.ComputeChange(attributes)
elements = append(elements, element)
current = compareActions(current, element.GetAction())
})
return change.New(change.NestedList(elements), current, v.replacePath())
}
func (v Value) processNestedList(process func(value Value)) {
sliceValue := v.asSlice()
for ix := 0; ix < len(sliceValue.Before) || ix < len(sliceValue.After); ix++ {
process(sliceValue.getChild(ix, ix, false))
}
}
func (v Value) processList(elementType cty.Type, process func(value Value)) {
sliceValue := v.asSlice()
lcs := lcs(sliceValue.Before, sliceValue.After)
var beforeIx, afterIx, lcsIx int
for beforeIx < len(sliceValue.Before) || afterIx < len(sliceValue.After) || lcsIx < len(lcs) {
// Step through all the before values until we hit the next item in the
// longest common subsequence. We are going to just say that all of
// these have been deleted.
for beforeIx < len(sliceValue.Before) && (lcsIx >= len(lcs) || !reflect.DeepEqual(sliceValue.Before[beforeIx], lcs[lcsIx])) {
isObjectDiff := elementType.IsObjectType() && afterIx < len(sliceValue.After) && (lcsIx >= len(lcs) || !reflect.DeepEqual(sliceValue.After[afterIx], lcs[lcsIx]))
if isObjectDiff {
process(sliceValue.getChild(beforeIx, afterIx, false))
beforeIx++
afterIx++
continue
}
process(sliceValue.getChild(beforeIx, len(sliceValue.After), false))
beforeIx++
}
// Now, step through all the after values until hit the next item in the
// LCS. We are going to say that all of these have been created.
for afterIx < len(sliceValue.After) && (lcsIx >= len(lcs) || !reflect.DeepEqual(sliceValue.After[afterIx], lcs[lcsIx])) {
process(sliceValue.getChild(len(sliceValue.Before), afterIx, false))
afterIx++
}
// Finally, add the item in common as unchanged.
if lcsIx < len(lcs) {
process(sliceValue.getChild(beforeIx, afterIx, false))
beforeIx++
afterIx++
lcsIx++
}
}
}
func lcs(xs, ys []interface{}) []interface{} {
if len(xs) == 0 || len(ys) == 0 {
return make([]interface{}, 0)
}
c := make([]int, len(xs)*len(ys))
eqs := make([]bool, len(xs)*len(ys))
w := len(xs)
for y := 0; y < len(ys); y++ {
for x := 0; x < len(xs); x++ {
eq := false
if reflect.DeepEqual(xs[x], ys[y]) {
eq = true
eqs[(w*y)+x] = true // equality tests can be expensive, so cache it
}
if eq {
// Sequence gets one longer than for the cell at top left,
// since we'd append a new item to the sequence here.
if x == 0 || y == 0 {
c[(w*y)+x] = 1
} else {
c[(w*y)+x] = c[(w*(y-1))+(x-1)] + 1
}
} else {
// We follow the longest of the sequence above and the sequence
// to the left of us in the matrix.
l := 0
u := 0
if x > 0 {
l = c[(w*y)+(x-1)]
}
if y > 0 {
u = c[(w*(y-1))+x]
}
if l > u {
c[(w*y)+x] = l
} else {
c[(w*y)+x] = u
}
}
}
}
// The bottom right cell tells us how long our longest sequence will be
seq := make([]interface{}, c[len(c)-1])
// Now we will walk back from the bottom right cell, finding again all
// of the equal pairs to construct our sequence.
x := len(xs) - 1
y := len(ys) - 1
i := len(seq) - 1
for x > -1 && y > -1 {
if eqs[(w*y)+x] {
// Add the value to our result list and then walk diagonally
// up and to the left.
seq[i] = xs[x]
x--
y--
i--
} else {
// Take the path with the greatest sequence length in the matrix.
l := 0
u := 0
if x > 0 {
l = c[(w*y)+(x-1)]
}
if y > 0 {
u = c[(w*(y-1))+x]
}
if l > u {
x--
} else {
y--
}
}
}
if i > -1 {
// should never happen if the matrix was constructed properly
panic("not enough elements in sequence")
}
return seq
}

View File

@ -1,9 +1,10 @@
package differ
import (
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/command/jsonformat/change"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
"github.com/zclconf/go-cty/cty"
)
func (v Value) computeAttributeChangeAsMap(elementType cty.Type) change.Change {

View File

@ -61,6 +61,9 @@ func (m ValueMap) processReplacePaths(key string) []interface{} {
path := p.([]interface{})
if len(path) == 0 {
// This means that the current value is causing a replacement but
// not its children, so we skip as we are returning the child's
// value.
continue
}

View File

@ -0,0 +1,96 @@
package differ
type ValueSlice struct {
// Before contains the value before the proposed change.
Before []interface{}
// After contains the value after the proposed change.
After []interface{}
// Unknown contains the unknown status of any elements of this list/set.
Unknown []interface{}
// BeforeSensitive contains the before sensitive status of any elements of
//this list/set.
BeforeSensitive []interface{}
// AfterSensitive contains the after sensitive status of any elements of
//this list/set.
AfterSensitive []interface{}
// ReplacePaths matches the same attributes in Value exactly.
ReplacePaths []interface{}
}
func (v Value) asSlice() ValueSlice {
return ValueSlice{
Before: genericToSlice(v.Before),
After: genericToSlice(v.After),
Unknown: genericToSlice(v.Unknown),
BeforeSensitive: genericToSlice(v.BeforeSensitive),
AfterSensitive: genericToSlice(v.AfterSensitive),
ReplacePaths: v.ReplacePaths,
}
}
func (s ValueSlice) getChild(beforeIx, afterIx int, propagateReplace bool) Value {
before, beforeExplicit := getFromGenericSlice(s.Before, beforeIx)
after, afterExplicit := getFromGenericSlice(s.After, afterIx)
unknown, _ := getFromGenericSlice(s.Unknown, afterIx)
beforeSensitive, _ := getFromGenericSlice(s.BeforeSensitive, beforeIx)
afterSensitive, _ := getFromGenericSlice(s.AfterSensitive, afterIx)
return Value{
BeforeExplicit: beforeExplicit,
AfterExplicit: afterExplicit,
Before: before,
After: after,
Unknown: unknown,
BeforeSensitive: beforeSensitive,
AfterSensitive: afterSensitive,
ReplacePaths: s.processReplacePaths(beforeIx, propagateReplace),
}
}
func (s ValueSlice) processReplacePaths(ix int, propagateReplace bool) []interface{} {
var ret []interface{}
for _, p := range s.ReplacePaths {
path := p.([]interface{})
if len(path) == 0 {
// This means that the current value is causing a replacement but
// not its children. Normally, we'd skip this as we do with maps
// but sets display the replace suffix on all their children even
// if they themselves are specified, so we want to pass this on.
if propagateReplace {
ret = append(ret, path)
}
// If we don't want to propagate the replace we just skip over this
// entry. If we do, we've added it to the returned set of paths
// already, so we still want to skip over the rest of this.
continue
}
if int(path[0].(float64)) == ix {
ret = append(ret, path[1:])
}
}
return ret
}
func getFromGenericSlice(generic []interface{}, ix int) (interface{}, bool) {
if generic == nil {
return nil, false
}
if ix < 0 || ix >= len(generic) {
return nil, false
}
return generic[ix], true
}
func genericToSlice(generic interface{}) []interface{} {
if concrete, ok := generic.([]interface{}); ok {
return concrete
}
return nil
}

View File

@ -422,6 +422,34 @@ func TestValue_ObjectAttributes(t *testing.T) {
validate(t, input.ComputeChange(attribute))
})
t.Run("list", func(t *testing.T) {
attribute := &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.Object(tc.attributes))),
}
input := wrapValueInSlice(tc.input)
if tc.validateObject != nil {
validate := change.ValidateList([]change.ValidateChangeFunc{
tc.validateObject,
}, collectionDefaultAction, false)
validate(t, input.ComputeChange(attribute))
return
}
if tc.validateSingleChange != nil {
validate := change.ValidateList([]change.ValidateChangeFunc{
tc.validateSingleChange,
}, collectionDefaultAction, false)
validate(t, input.ComputeChange(attribute))
return
}
validate := change.ValidateList([]change.ValidateChangeFunc{
change.ValidateObject(tc.validateChanges, tc.validateAction, tc.validateReplace),
}, collectionDefaultAction, false)
validate(t, input.ComputeChange(attribute))
})
})
t.Run(fmt.Sprintf("nested_%s", name), func(t *testing.T) {
@ -494,6 +522,46 @@ func TestValue_ObjectAttributes(t *testing.T) {
}, collectionDefaultAction, false)
validate(t, input.ComputeChange(attribute))
})
t.Run("list", func(t *testing.T) {
attribute := &jsonprovider.Attribute{
AttributeNestedType: &jsonprovider.NestedType{
Attributes: func() map[string]*jsonprovider.Attribute {
attributes := make(map[string]*jsonprovider.Attribute)
for key, attribute := range tc.attributes {
attributes[key] = &jsonprovider.Attribute{
AttributeType: unmarshalType(t, attribute),
}
}
return attributes
}(),
NestingMode: "list",
},
}
input := wrapValueInSlice(tc.input)
if tc.validateNestedObject != nil {
validate := change.ValidateNestedList([]change.ValidateChangeFunc{
tc.validateNestedObject,
}, collectionDefaultAction, false)
validate(t, input.ComputeChange(attribute))
return
}
if tc.validateSingleChange != nil {
validate := change.ValidateNestedList([]change.ValidateChangeFunc{
tc.validateSingleChange,
}, collectionDefaultAction, false)
validate(t, input.ComputeChange(attribute))
return
}
validate := change.ValidateNestedList([]change.ValidateChangeFunc{
change.ValidateNestedObject(tc.validateChanges, tc.validateAction, tc.validateReplace),
}, collectionDefaultAction, false)
validate(t, input.ComputeChange(attribute))
})
})
}
}
@ -504,9 +572,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
// contexts of collections.
tcs := map[string]struct {
input Value
attribute cty.Type
validateChange change.ValidateChangeFunc
input Value
attribute cty.Type
validateChange change.ValidateChangeFunc
validateListChanges []change.ValidateChangeFunc // Lists are special in some cases.
}{
"primitive_create": {
input: Value{
@ -529,6 +598,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
},
attribute: cty.String,
validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\""), plans.Update, false),
validateListChanges: []change.ValidateChangeFunc{
change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false),
},
},
"primitive_set_explicit_null": {
input: Value{
@ -538,6 +611,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
},
attribute: cty.String,
validateChange: change.ValidatePrimitive(strptr("\"old\""), nil, plans.Update, false),
validateListChanges: []change.ValidateChangeFunc{
change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
change.ValidatePrimitive(nil, nil, plans.Create, false),
},
},
"primitive_unset_explicit_null": {
input: Value{
@ -547,6 +624,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
},
attribute: cty.String,
validateChange: change.ValidatePrimitive(nil, strptr("\"new\""), plans.Update, false),
validateListChanges: []change.ValidateChangeFunc{
change.ValidatePrimitive(nil, nil, plans.Delete, false),
change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false),
},
},
"primitive_create_sensitive": {
input: Value{
@ -575,6 +656,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
},
attribute: cty.String,
validateChange: change.ValidateSensitive("old", "new", true, true, plans.Update, false),
validateListChanges: []change.ValidateChangeFunc{
change.ValidateSensitive("old", nil, true, false, plans.Delete, false),
change.ValidateSensitive(nil, "new", false, true, plans.Create, false),
},
},
"primitive_create_computed": {
input: Value{
@ -593,6 +678,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
},
attribute: cty.String,
validateChange: change.ValidateComputed(change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), plans.Update, false),
validateListChanges: []change.ValidateChangeFunc{
change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false),
change.ValidateComputed(nil, plans.Create, false),
},
},
"primitive_update_replace": {
input: Value{
@ -604,6 +693,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
},
attribute: cty.String,
validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\""), plans.Update, true),
validateListChanges: []change.ValidateChangeFunc{
change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, true),
change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false),
},
},
"noop": {
input: Value{
@ -640,6 +733,24 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
}, defaultCollectionsAction, false)
validate(t, input.ComputeChange(attribute))
})
t.Run("list", func(t *testing.T) {
input := wrapValueInSlice(tc.input)
attribute := &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(tc.attribute)),
}
if tc.validateListChanges != nil {
validate := change.ValidateList(tc.validateListChanges, defaultCollectionsAction, false)
validate(t, input.ComputeChange(attribute))
return
}
validate := change.ValidateList([]change.ValidateChangeFunc{
tc.validateChange,
}, defaultCollectionsAction, false)
validate(t, input.ComputeChange(attribute))
})
})
}
}
@ -765,6 +876,108 @@ func TestValue_CollectionAttributes(t *testing.T) {
},
validateChange: change.ValidateComputed(change.ValidateMap(nil, plans.Delete, false), plans.Update, false),
},
"list_create_empty": {
input: Value{
Before: nil,
After: []interface{}{},
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateList(nil, plans.Create, false),
},
"list_create_populated": {
input: Value{
Before: nil,
After: []interface{}{"one", "two"},
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateList([]change.ValidateChangeFunc{
change.ValidatePrimitive(nil, strptr("\"one\""), plans.Create, false),
change.ValidatePrimitive(nil, strptr("\"two\""), plans.Create, false),
}, plans.Create, false),
},
"list_delete_empty": {
input: Value{
Before: []interface{}{},
After: nil,
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateList(nil, plans.Delete, false),
},
"list_delete_populated": {
input: Value{
Before: []interface{}{"one", "two"},
After: nil,
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateList([]change.ValidateChangeFunc{
change.ValidatePrimitive(strptr("\"one\""), nil, plans.Delete, false),
change.ValidatePrimitive(strptr("\"two\""), nil, plans.Delete, false),
}, plans.Delete, false),
},
"list_create_sensitive": {
input: Value{
Before: nil,
After: []interface{}{},
AfterSensitive: true,
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateSensitive(nil, []interface{}{}, false, true, plans.Create, false),
},
"list_update_sensitive": {
input: Value{
Before: []interface{}{"one"},
BeforeSensitive: true,
After: []interface{}{},
AfterSensitive: true,
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateSensitive([]interface{}{"one"}, []interface{}{}, true, true, plans.Update, false),
},
"list_delete_sensitive": {
input: Value{
Before: []interface{}{},
BeforeSensitive: true,
After: nil,
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateSensitive([]interface{}{}, nil, true, false, plans.Delete, false),
},
"list_create_unknown": {
input: Value{
Before: nil,
After: []interface{}{},
Unknown: true,
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateComputed(nil, plans.Create, false),
},
"list_update_unknown": {
input: Value{
Before: []interface{}{},
After: []interface{}{"one"},
Unknown: true,
},
attribute: &jsonprovider.Attribute{
AttributeType: unmarshalType(t, cty.List(cty.String)),
},
validateChange: change.ValidateComputed(change.ValidateList(nil, plans.Delete, false), plans.Update, false),
},
}
for name, tc := range tcs {
@ -785,10 +998,27 @@ func unmarshalType(t *testing.T, ctyType cty.Type) json.RawMessage {
return msg
}
// wrapValueInSlice does the same as wrapValueInMap, except it wraps it into a
// slice internally.
func wrapValueInSlice(input Value) Value {
return wrapValue(input, float64(0), func(value interface{}, unknown interface{}, explicit bool) interface{} {
switch value.(type) {
case nil:
if set, ok := unknown.(bool); (set && ok) || explicit {
return []interface{}{nil}
}
return []interface{}{}
default:
return []interface{}{value}
}
})
}
// wrapValueInMap access a single Value and returns a new Value that represents
// a map with a single element. That single element is the input value.
func wrapValueInMap(input Value) Value {
tomap := func(value interface{}, unknown interface{}, explicit bool) interface{} {
return wrapValue(input, "element", func(value interface{}, unknown interface{}, explicit bool) interface{} {
switch value.(type) {
case nil:
if set, ok := unknown.(bool); (set && ok) || explicit {
@ -802,20 +1032,22 @@ func wrapValueInMap(input Value) Value {
"element": value,
}
}
}
})
}
func wrapValue(input Value, step interface{}, wrap func(interface{}, interface{}, bool) interface{}) Value {
return Value{
Before: tomap(input.Before, nil, input.BeforeExplicit),
After: tomap(input.After, input.Unknown, input.AfterExplicit),
Unknown: tomap(input.Unknown, nil, false),
BeforeSensitive: tomap(input.BeforeSensitive, nil, false),
AfterSensitive: tomap(input.AfterSensitive, nil, false),
Before: wrap(input.Before, nil, input.BeforeExplicit),
After: wrap(input.After, input.Unknown, input.AfterExplicit),
Unknown: wrap(input.Unknown, nil, false),
BeforeSensitive: wrap(input.BeforeSensitive, nil, false),
AfterSensitive: wrap(input.AfterSensitive, nil, false),
ReplacePaths: func() []interface{} {
var ret []interface{}
for _, path := range input.ReplacePaths {
old := path.([]interface{})
var updated []interface{}
updated = append(updated, "element")
updated = append(updated, step)
updated = append(updated, old...)
ret = append(ret, updated)
}