Merge pull request #298 from jpbetz/test-key-changes

Add associative list key schema evolution changes
diff --git a/fieldpath/set_test.go b/fieldpath/set_test.go
index 0254e2d..0685bc4 100644
--- a/fieldpath/set_test.go
+++ b/fieldpath/set_test.go
@@ -697,6 +697,58 @@
 	return parser, name
 }
 
+var associativeListSchema = func() (*typed.Parser, string) {
+	name := "type"
+	parser := mustParse(`types:
+- name: type
+  map:
+    fields:
+      - name: values
+        type:
+          list:
+            elementRelationship: associative
+            keys: ["keyAStr", "keyBInt"]
+            elementType:
+              map:
+                fields:
+                  - name: keyAStr
+                    type:
+                      scalar: string
+                  - name: keyBInt
+                    type:
+                      scalar: numeric
+                  - name: value
+                    type:
+                      scalar: numeric
+`)
+	return parser, name
+}
+
+var oldAssociativeListSchema = func() (*typed.Parser, string) {
+	name := "type"
+	//  No keyBInt yet!
+	parser := mustParse(`types:
+- name: type
+  map:
+    fields:
+      - name: values
+        type:
+          list:
+            elementRelationship: associative
+            keys: ["keyAStr"]
+            elementType:
+              map:
+                fields:
+                  - name: keyAStr
+                    type:
+                      scalar: string
+                  - name: value
+                    type:
+                      scalar: numeric
+`)
+	return parser, name
+}
+
 func mustParse(schema typed.YAMLObject) *typed.Parser {
 	parser, err := typed.NewParser(schema)
 	if err != nil {
@@ -707,12 +759,15 @@
 
 func TestEnsureNamedFieldsAreMembers(t *testing.T) {
 	table := []struct {
+		schemaFn       func() (*typed.Parser, string)
+		newSchemaFn    func() (*typed.Parser, string)
 		value          typed.YAMLObject
 		expectedBefore *Set
 		expectedAfter  *Set
 	}{
 		{
-			value: `{"named": {"named": {"value": 0}}}`,
+			schemaFn: nestedSchema,
+			value:    `{"named": {"named": {"value": 0}}}`,
 			expectedBefore: NewSet(
 				_P("named", "named", "value"),
 			),
@@ -723,7 +778,8 @@
 			),
 		},
 		{
-			value: `{"named": {"a": {"named": {"value": 42}}}, "a": {"named": {"value": 1}}}`,
+			schemaFn: nestedSchema,
+			value:    `{"named": {"a": {"named": {"value": 42}}}, "a": {"named": {"value": 1}}}`,
 			expectedBefore: NewSet(
 				_P("named", "a", "named", "value"),
 				_P("a", "named", "value"),
@@ -739,7 +795,8 @@
 			),
 		},
 		{
-			value: `{"named": {"list": [{"keyAStr": "a", "keyBInt": 1, "named": {"value": 0}}]}}`,
+			schemaFn: nestedSchema,
+			value:    `{"named": {"list": [{"keyAStr": "a", "keyBInt": 1, "named": {"value": 0}}]}}`,
 			expectedBefore: NewSet(
 				_P("named", "list", KeyByFields("keyAStr", "a", "keyBInt", 1), "keyAStr"),
 				_P("named", "list", KeyByFields("keyAStr", "a", "keyBInt", 1), "keyBInt"),
@@ -756,11 +813,46 @@
 				_P("named"),
 			),
 		},
+		{
+			// Generate the value using the old schema to get missing key entries,
+			// then process with new schema which has keyBInt.
+			schemaFn:    oldAssociativeListSchema,
+			newSchemaFn: associativeListSchema,
+			value:       `{"values": [{"keyAStr": "a", "value": 0}]}`,
+			expectedBefore: NewSet(
+				_P("values", KeyByFields("keyAStr", "a"), "keyAStr"),
+				_P("values", KeyByFields("keyAStr", "a"), "value"),
+				_P("values", KeyByFields("keyAStr", "a")),
+			),
+			expectedAfter: NewSet(
+				_P("values", KeyByFields("keyAStr", "a"), "keyAStr"),
+				_P("values", KeyByFields("keyAStr", "a"), "value"),
+				_P("values", KeyByFields("keyAStr", "a")),
+				_P("values"),
+			),
+		},
+		{
+			// Check that converting the value with the missing key and
+			// the recent schema doesn't add the missing key.
+			schemaFn: associativeListSchema,
+			value:    `{"values": [{"keyAStr": "a", "value": 1}]}`,
+			expectedBefore: NewSet(
+				_P("values", KeyByFields("keyAStr", "a"), "keyAStr"),
+				_P("values", KeyByFields("keyAStr", "a"), "value"),
+				_P("values", KeyByFields("keyAStr", "a")),
+			),
+			expectedAfter: NewSet(
+				_P("values", KeyByFields("keyAStr", "a"), "keyAStr"),
+				_P("values", KeyByFields("keyAStr", "a"), "value"),
+				_P("values", KeyByFields("keyAStr", "a")),
+				_P("values"),
+			),
+		},
 	}
 
 	for _, test := range table {
 		t.Run(string(test.value), func(t *testing.T) {
-			parser, typeName := nestedSchema()
+			parser, typeName := test.schemaFn()
 			typeRef := schema.TypeRef{NamedType: &typeName}
 			typedValue, err := parser.Type(typeName).FromYAML(test.value)
 			if err != nil {
@@ -780,6 +872,11 @@
 			}
 
 			schema := &parser.Schema
+			if test.newSchemaFn != nil {
+				newParser, _ := test.newSchemaFn()
+				schema = &newParser.Schema
+			}
+
 			got := set.EnsureNamedFieldsAreMembers(schema, typeRef)
 			if !got.Equals(test.expectedAfter) {
 				t.Errorf("expected after EnsureNamedFieldsAreMembers:\n%v\n\ngot:\n%v\n\nmissing:\n%v\n\nsuperfluous:\n\n%v",
diff --git a/merge/default_keys_test.go b/merge/default_keys_test.go
index 24b202d..28f8103 100644
--- a/merge/default_keys_test.go
+++ b/merge/default_keys_test.go
@@ -163,6 +163,36 @@
 				),
 			},
 		},
+		"apply_missing_undefaulted_defaulted_key": {
+			Ops: []Operation{
+				Apply{
+					Manager:    "default",
+					APIVersion: "v1",
+					Object: `
+						containerPorts:
+						- protocol: TCP
+						  name: A
+					`,
+				},
+			},
+			APIVersion: "v1",
+			Object: `
+				containerPorts:
+				- protocol: TCP
+				  name: A
+			`,
+			Managed: fieldpath.ManagedFields{
+				"default": fieldpath.NewVersionedSet(
+					_NS(
+						_P("containerPorts", _KBF("protocol", "TCP")),
+						_P("containerPorts", _KBF("protocol", "TCP"), "name"),
+						_P("containerPorts", _KBF("protocol", "TCP"), "protocol"),
+					),
+					"v1",
+					true,
+				),
+			},
+		},
 	}
 
 	for name, test := range tests {
@@ -176,18 +206,6 @@
 
 func TestDefaultKeysFlatErrors(t *testing.T) {
 	tests := map[string]TestCase{
-		"apply_missing_undefaulted_defaulted_key": {
-			Ops: []Operation{
-				Apply{
-					Manager:    "default",
-					APIVersion: "v1",
-					Object: `
-						containerPorts:
-						- protocol: TCP
-					`,
-				},
-			},
-		},
 		"apply_missing_defaulted_key_ambiguous_A": {
 			Ops: []Operation{
 				Apply{
diff --git a/merge/schema_change_test.go b/merge/schema_change_test.go
index d733407..db78ff5 100644
--- a/merge/schema_change_test.go
+++ b/merge/schema_change_test.go
@@ -252,3 +252,459 @@
 		})
 	}
 }
+
+var associativeListParserOld = func() *typed.Parser {
+	oldParser, err := typed.NewParser(`types:
+- name: v1
+  map:
+    fields:
+      - name: list
+        type:
+          namedType: associativeList
+- name: associativeList
+  list:
+    elementType:
+      namedType: myElement
+    elementRelationship: associative
+    keys:
+    - name
+- name: myElement
+  map:
+    fields:
+    - name: name
+      type:
+        scalar: string
+    - name: value
+      type:
+        scalar: numeric
+`)
+	if err != nil {
+		panic(err)
+	}
+	return oldParser
+}()
+
+var associativeListParserNewOptionalKey = func() *typed.Parser {
+	newParser, err := typed.NewParser(`types:
+- name: v1
+  map:
+    fields:
+      - name: list
+        type:
+          namedType: associativeList
+- name: associativeList
+  list:
+    elementType:
+      namedType: myElement
+    elementRelationship: associative
+    keys:
+    - name
+    - id
+- name: myElement
+  map:
+    fields:
+    - name: name
+      type:
+        scalar: string
+    - name: id
+      type:
+        scalar: numeric
+    - name: value
+      type:
+        scalar: numeric
+`)
+	if err != nil {
+		panic(err)
+	}
+	return newParser
+}()
+
+var associativeListParserNewKeyWithDefault = func() *typed.Parser {
+	newParser, err := typed.NewParser(`types:
+- name: v1
+  map:
+    fields:
+      - name: list
+        type:
+          namedType: associativeList
+- name: associativeList
+  list:
+    elementType:
+      namedType: myElement
+    elementRelationship: associative
+    keys:
+    - name
+    - id
+- name: myElement
+  map:
+    fields:
+    - name: name
+      type:
+        scalar: string
+    - name: id
+      type:
+        scalar: numeric
+      default: 1
+    - name: value
+      type:
+        scalar: numeric
+`)
+	if err != nil {
+		panic(err)
+	}
+	return newParser
+}()
+
+func TestAssociativeListSchemaChanges(t *testing.T) {
+	tests := map[string]TestCase{
+		"new required key with default": {
+			Ops: []Operation{
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  value: 1
+						- name: b
+						  value: 1
+						- name: c
+						  value: 1
+					`,
+					APIVersion: "v1",
+				},
+				ChangeParser{Parser: associativeListParserNewKeyWithDefault},
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  value: 2
+						- name: b
+						  id: 1
+						  value: 2
+						- name: c
+						  value: 1
+						- name: c
+						  id: 2
+						  value: 2
+						- name: c
+						  id: 3
+						  value: 3
+					`,
+					APIVersion: "v1",
+				},
+			},
+			Object: `
+				list:
+				- name: a
+				  value: 2
+				- name: b
+				  id: 1
+				  value: 2
+				- name: c
+				  value: 1
+				- name: c
+				  id: 2
+				  value: 2
+				- name: c
+				  id: 3
+				  value: 3
+			`,
+			APIVersion: "v1",
+			Managed: fieldpath.ManagedFields{
+				"one": fieldpath.NewVersionedSet(_NS(
+					_P("list", _KBF("name", "a", "id", float64(1))),
+					_P("list", _KBF("name", "a", "id", float64(1)), "name"),
+					_P("list", _KBF("name", "a", "id", float64(1)), "value"),
+					_P("list", _KBF("name", "b", "id", float64(1))),
+					_P("list", _KBF("name", "b", "id", float64(1)), "name"),
+					_P("list", _KBF("name", "b", "id", float64(1)), "id"),
+					_P("list", _KBF("name", "b", "id", float64(1)), "value"),
+					_P("list", _KBF("name", "c", "id", float64(1))),
+					_P("list", _KBF("name", "c", "id", float64(1)), "name"),
+					_P("list", _KBF("name", "c", "id", float64(1)), "value"),
+					_P("list", _KBF("name", "c", "id", float64(2))),
+					_P("list", _KBF("name", "c", "id", float64(2)), "name"),
+					_P("list", _KBF("name", "c", "id", float64(2)), "id"),
+					_P("list", _KBF("name", "c", "id", float64(2)), "value"),
+					_P("list", _KBF("name", "c", "id", float64(3))),
+					_P("list", _KBF("name", "c", "id", float64(3)), "name"),
+					_P("list", _KBF("name", "c", "id", float64(3)), "id"),
+					_P("list", _KBF("name", "c", "id", float64(3)), "value"),
+				), "v1", true),
+			},
+		},
+		"new optional key": {
+			Ops: []Operation{
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  value: 1
+					`,
+					APIVersion: "v1",
+				},
+				ChangeParser{Parser: associativeListParserNewOptionalKey},
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  value: 2
+						- name: a
+						  id: 1
+						  value: 1
+					`,
+					APIVersion: "v1",
+				},
+			},
+			Object: `
+				list:
+				- name: a
+				  value: 2
+				- name: a
+				  id: 1
+				  value: 1
+			`,
+			APIVersion: "v1",
+			Managed: fieldpath.ManagedFields{
+				"one": fieldpath.NewVersionedSet(_NS(
+					_P("list", _KBF("name", "a")),
+					_P("list", _KBF("name", "a"), "name"),
+					_P("list", _KBF("name", "a"), "value"),
+					_P("list", _KBF("name", "a", "id", float64(1))),
+					_P("list", _KBF("name", "a", "id", float64(1)), "name"),
+					_P("list", _KBF("name", "a", "id", float64(1)), "id"),
+					_P("list", _KBF("name", "a", "id", float64(1)), "value"),
+				), "v1", true),
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			if err := test.Test(associativeListParserOld); err != nil {
+				t.Fatal(err)
+			}
+		})
+	}
+}
+
+var associativeListParserPromoteKeyBefore = func() *typed.Parser {
+	p, err := typed.NewParser(`types:
+- name: v1
+  map:
+    fields:
+      - name: list
+        type:
+          namedType: associativeList
+- name: associativeList
+  list:
+    elementType:
+      namedType: myElement
+    elementRelationship: associative
+    keys:
+    - name
+- name: myElement
+  map:
+    fields:
+    - name: name
+      type:
+        scalar: string
+    - name: id
+      type:
+        scalar: numeric
+    - name: value
+      type:
+        scalar: numeric
+`)
+	if err != nil {
+		panic(err)
+	}
+	return p
+}()
+
+var associativeListParserPromoteKeyAfter = func() *typed.Parser {
+	p, err := typed.NewParser(`types:
+- name: v1
+  map:
+    fields:
+      - name: list
+        type:
+          namedType: associativeList
+- name: associativeList
+  list:
+    elementType:
+      namedType: myElement
+    elementRelationship: associative
+    keys:
+    - name
+    - id
+- name: myElement
+  map:
+    fields:
+    - name: name
+      type:
+        scalar: string
+    - name: id
+      type:
+        scalar: numeric
+    - name: value
+      type:
+        scalar: numeric
+`)
+	if err != nil {
+		panic(err)
+	}
+	return p
+}()
+
+func TestPromoteFieldToAssociativeListKey(t *testing.T) {
+	tests := map[string]TestCase{
+		"identical item merges": {
+			Ops: []Operation{
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  id: 1
+						  value: 1
+					`,
+					APIVersion: "v1",
+				},
+				ChangeParser{Parser: associativeListParserPromoteKeyAfter},
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  id: 1
+						  value: 2
+					`,
+					APIVersion: "v1",
+				},
+			},
+			Object: `
+				list:
+				- name: a
+				  id: 1
+				  value: 2
+			`,
+			APIVersion: "v1",
+			Managed: fieldpath.ManagedFields{
+				"one": fieldpath.NewVersionedSet(_NS(
+					_P("list", _KBF("name", "a", "id", float64(1))),
+					_P("list", _KBF("name", "a", "id", float64(1)), "name"),
+					_P("list", _KBF("name", "a", "id", float64(1)), "id"),
+					_P("list", _KBF("name", "a", "id", float64(1)), "value"),
+				), "v1", true),
+			},
+		},
+		"distinct item added": {
+			Ops: []Operation{
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  id: 1
+						  value: 1
+					`,
+					APIVersion: "v1",
+				},
+				ChangeParser{Parser: associativeListParserPromoteKeyAfter},
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  id: 1
+						  value: 1
+						- name: a
+						  id: 2
+						  value: 2
+					`,
+					APIVersion: "v1",
+				},
+			},
+			Object: `
+				list:
+				- name: a
+				  id: 1
+				  value: 1
+				- name: a
+				  id: 2
+				  value: 2
+			`,
+			APIVersion: "v1",
+			Managed: fieldpath.ManagedFields{
+				"one": fieldpath.NewVersionedSet(_NS(
+					_P("list", _KBF("name", "a", "id", float64(1))),
+					_P("list", _KBF("name", "a", "id", float64(1)), "name"),
+					_P("list", _KBF("name", "a", "id", float64(1)), "id"),
+					_P("list", _KBF("name", "a", "id", float64(1)), "value"),
+					_P("list", _KBF("name", "a", "id", float64(2))),
+					_P("list", _KBF("name", "a", "id", float64(2)), "name"),
+					_P("list", _KBF("name", "a", "id", float64(2)), "id"),
+					_P("list", _KBF("name", "a", "id", float64(2)), "value"),
+				), "v1", true),
+			},
+		},
+		"item missing new key field is distinct": {
+			Ops: []Operation{
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  value: 1
+					`,
+					APIVersion: "v1",
+				},
+				ChangeParser{Parser: associativeListParserPromoteKeyAfter},
+				Apply{
+					Manager: "one",
+					Object: `
+						list:
+						- name: a
+						  value: 1
+						- name: a
+						  id: 2
+						  value: 2
+					`,
+					APIVersion: "v1",
+				},
+			},
+			Object: `
+				list:
+				- name: a
+				  value: 1
+				- name: a
+				  id: 2
+				  value: 2
+			`,
+			APIVersion: "v1",
+			Managed: fieldpath.ManagedFields{
+				"one": fieldpath.NewVersionedSet(_NS(
+					_P("list", _KBF("name", "a")),
+					_P("list", _KBF("name", "a"), "name"),
+					_P("list", _KBF("name", "a"), "value"),
+					_P("list", _KBF("name", "a", "id", float64(2))),
+					_P("list", _KBF("name", "a", "id", float64(2)), "name"),
+					_P("list", _KBF("name", "a", "id", float64(2)), "id"),
+					_P("list", _KBF("name", "a", "id", float64(2)), "value"),
+				), "v1", true),
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			if err := test.Test(associativeListParserPromoteKeyBefore); err != nil {
+				t.Fatal(err)
+			}
+		})
+	}
+}
diff --git a/typed/helpers.go b/typed/helpers.go
index f10ac6e..8a9c0b5 100644
--- a/typed/helpers.go
+++ b/typed/helpers.go
@@ -217,9 +217,16 @@
 		} else if def != nil {
 			keyMap = append(keyMap, value.Field{Name: fieldName, Value: value.NewValueInterface(def)})
 		} else {
-			return pe, fmt.Errorf("associative list with keys has an element that omits key field %q (and doesn't have default value)", fieldName)
+			// Don't add the key to the key field list.
+			// A key field list where it is set then represents a different entry
+			// in the associate list.
 		}
 	}
+
+	if len(list.Keys) > 0 && len(keyMap) == 0 {
+		return pe, fmt.Errorf("associative list with keys has an element that omits all key fields %q (and doesn't have default values for any key fields)", list.Keys)
+	}
+
 	keyMap.Sort()
 	pe.Key = &keyMap
 	return pe, nil
diff --git a/typed/validate_test.go b/typed/validate_test.go
index b4c177a..92ba141 100644
--- a/typed/validate_test.go
+++ b/typed/validate_test.go
@@ -213,6 +213,7 @@
 		`{"list":[{"key":"a","id":1,"value":{"a":"a"}}]}`,
 		`{"list":[{"key":"a","id":1},{"key":"a","id":2},{"key":"b","id":1}]}`,
 		`{"atomicList":["a","a","a"]}`,
+		`{"list":[{"key":"x","value":{"a":"a"},"bv":true,"nv":3.14}]}`,
 	},
 	invalidObjects: []typed.YAMLObject{
 		`{"key":true,"value":1}`,