diff --git a/mmv1/products/dataplex/EntryLink.yaml b/mmv1/products/dataplex/EntryLink.yaml index 2a2d01e1fa6d..1bd32f6f7a9b 100644 --- a/mmv1/products/dataplex/EntryLink.yaml +++ b/mmv1/products/dataplex/EntryLink.yaml @@ -17,17 +17,27 @@ description: | EntryLink represents a link between two Entries. references: guides: - 'Official Documentation': 'https://cloud.google.com/dataplex/docs' # Update with specific EntryLink docs when available + 'Official Documentation': 'https://cloud.google.com/dataplex/docs' # Update with specific EntryLink docs when available api: 'https://cloud.google.com/dataplex/docs/reference/rest/v1/projects.locations.entryGroups.entryLinks' docs: base_url: 'projects/{{project}}/locations/{{location}}/entryGroups/{{entry_group_id}}/entryLinks/{{entry_link_id}}' self_link: 'projects/{{project}}/locations/{{location}}/entryGroups/{{entry_group_id}}/entryLinks/{{entry_link_id}}' create_url: 'projects/{{project}}/locations/{{location}}/entryGroups/{{entry_group_id}}/entryLinks?entryLinkId={{entry_link_id}}' -immutable: true +update_verb: 'PATCH' import_format: - 'projects/{{project}}/locations/{{location}}/entryGroups/{{entry_group_id}}/entryLinks/{{entry_link_id}}' + +custom_code: + constants: templates/terraform/constants/dataplex_entry_link.go.tmpl + decoder: templates/terraform/decoders/dataplex_entry_link.go.tmpl + encoder: templates/terraform/encoders/dataplex_entry_link.go.tmpl + +error_retry_predicates: + - 'transport_tpg.IsDataplex1PEntryNotFoundError' + timeouts: insert_minutes: 20 + update_minutes: 20 delete_minutes: 20 examples: - name: 'dataplex_entry_link_basic' @@ -42,23 +52,37 @@ examples: entry_link_name: 'my_entry_link_full' test_env_vars: project_number: 'PROJECT_NUMBER' + - name: 'dataplex_entry_link_with_aspect' + primary_resource_id: 'full_entry_link_with_aspect' + primary_resource_name: 'fmt.Sprintf("tf-test-entry-link-full-with-aspect%s", context["random_suffix"])' + ignore_read_extra: + - 'aspects' + vars: + entry_link_name: 'my_entry_link_full_with_aspect' + test_env_vars: + project_number: 'PROJECT_NUMBER' + project_id: 'PROJECT_NAME' + parameters: - name: 'entryGroupId' type: String description: | The id of the entry group this entry link is in. url_param_only: true + immutable: true required: true - name: 'entryLinkId' type: String description: | The id of the entry link to create. url_param_only: true + immutable: true required: true - name: location type: String description: The location for the entry. url_param_only: true + immutable: true required: true properties: - name: 'name' @@ -67,12 +91,14 @@ properties: The relative resource name of the Entry Link, of the form: projects/{project_id_or_number}/locations/{location_id}/entryGroups/{entry_group_id}/entryLinks/{entry_link_id} output: true + immutable: true - name: 'entryLinkType' type: String description: | Relative resource name of the Entry Link Type used to create this Entry Link. For example: projects/dataplex-types/locations/global/entryLinkTypes/definition required: true + immutable: true - name: 'createTime' type: Time description: | @@ -88,6 +114,7 @@ properties: description: | Specifies the Entries referenced in the Entry Link. There should be exactly two entry references. required: true + immutable: true item_type: type: NestedObject properties: @@ -109,3 +136,55 @@ properties: enum_values: - 'SOURCE' - 'TARGET' + - name: 'aspects' + type: Array + custom_flatten: 'templates/terraform/custom_flatten/dataplex_entry_link_aspects.go.tmpl' + description: | + The Aspects attached to the Entry Link. + item_type: + type: NestedObject + properties: + - name: 'aspectKey' + type: String + required: true + description: | + The map keys of the Aspects which the service should modify. + It should be the aspect type reference in the format `{project_number}.{location_id}.{aspect_type_id}`. + - name: 'aspect' + type: NestedObject + required: true + properties: + - name: 'aspectType' + type: String + output: true + description: | + The resource name of the type used to create this Aspect. + + - name: 'path' + type: String + output: true + description: | + The path in the entry link under which the aspect is attached. + + - name: 'createTime' + type: Time + output: true + description: | + The time when the Aspect was created. + + - name: 'updateTime' + type: Time + output: true + description: | + The time when the Aspect was last modified. + + - name: 'data' + type: String + required: true + state_func: 'func(v interface{}) string { s, _ := structure.NormalizeJsonString(v); return s }' + custom_flatten: 'templates/terraform/custom_flatten/json_schema.tmpl' + custom_expand: 'templates/terraform/custom_expand/json_schema.tmpl' + validation: + function: 'validation.StringIsJSON' + description: | + The content of the aspect in JSON form, according to its aspect type schema. The maximum size of the field is 120KB (encoded as UTF-8). diff --git a/mmv1/templates/terraform/constants/dataplex_entry_link.go.tmpl b/mmv1/templates/terraform/constants/dataplex_entry_link.go.tmpl new file mode 100644 index 000000000000..43b7d78d1636 --- /dev/null +++ b/mmv1/templates/terraform/constants/dataplex_entry_link.go.tmpl @@ -0,0 +1,152 @@ +var ( + entryLinkProjectNumberRegex = regexp.MustCompile(`^projects\/[1-9]\d*\/.+$`) +) + +// EntryLinkProjectNumberValidation checks if the input string conforms to the pattern: +// "projects//" +func EntryLinkProjectNumberValidation(i interface{}, k string) (warnings []string, errors []error) { + v, ok := i.(string) + + if !ok { + errors = append(errors, fmt.Errorf("expected type of field %q to be string, but got %T", k, i)) + return warnings, errors + } + + if !entryLinkProjectNumberRegex.MatchString(v) { + errors = append(errors, fmt.Errorf( + "field %q has an invalid format: %q. Expected format: 'projects//'. Please note that project IDs are not supported.", + k, v, + )) + } + + return warnings, errors +} + +// FilterEntryLinkAspects filters the aspects in res based on aspectKeySet. +// It returns an error if type assertions fail. +func FilterEntryLinkAspects(aspectKeySet map[string]struct{}, res map[string]interface{}) error { + aspectsRaw, ok := res["aspects"] + if !ok || aspectsRaw == nil { + return nil + } + + aspectsMap, ok := aspectsRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("FilterEntryLinkAspects: 'aspects' field is not a map[string]interface{}, got %T", aspectsRaw) + } + + for key := range aspectsMap { + if _, keep := aspectKeySet[key]; !keep { + delete(aspectsMap, key) + } + } + return nil +} + +// AddEntryLinkAspectsToSet adds aspect keys from the aspects interface to the aspectKeySet. +// It returns an error if type assertions fail or expected keys are missing. +func AddEntryLinkAspectsToSet(aspectKeySet map[string]struct{}, aspects interface{}) error { + if aspects == nil { + return nil + } + aspectsSlice, ok := aspects.([]interface{}) + if !ok { + return fmt.Errorf("AddEntryLinkAspectsToSet: input 'aspects' is not a []interface{}, got %T", aspects) + } + + for i, aspectItemRaw := range aspectsSlice { + aspectMap, ok := aspectItemRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("AddEntryLinkAspectsToSet: item at index %d is not a map[string]interface{}, got %T", i, aspectItemRaw) + } + + keyRaw, keyExists := aspectMap["aspect_key"] + if !keyExists { + return fmt.Errorf("AddEntryLinkAspectsToSet: 'aspect_key' not found in aspect item at index %d", i) + } + + keyString, ok := keyRaw.(string) + if !ok { + return fmt.Errorf("AddEntryLinkAspectsToSet: 'aspect_key' in item at index %d is not a string, got %T", i, keyRaw) + } + aspectKeySet[keyString] = struct{}{} + } + return nil +} + +// InverseTransformEntryLinkAspects converts the "aspects" map back to a slice of maps, +// re-inserting the "aspectKey". Modifies obj in-place. +// It returns an error if type assertions fail. +func InverseTransformEntryLinkAspects(res map[string]interface{}) error { + aspectsRaw, ok := res["aspects"] + if !ok || aspectsRaw == nil { + return nil + } + + originalMap, ok := aspectsRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("InverseTransformEntryLinkAspects: 'aspects' field is not a map[string]interface{}, got %T", aspectsRaw) + } + + newSlice := make([]interface{}, 0, len(originalMap)) + + for key, value := range originalMap { + innerMap, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("InverseTransformEntryLinkAspects: value for key '%s' is not a map[string]interface{}, got %T", key, value) + } + box := make(map[string]interface{}, 2) + box["aspectKey"] = key + box["aspect"] = innerMap + newSlice = append(newSlice, box) + } + res["aspects"] = newSlice + return nil +} + +// TransformEntryLinkAspects concisely transforms the "aspects" slice within obj into a map. +// Modifies obj in-place. +// It returns an error if type assertions fail or expected keys are missing. +func TransformEntryLinkAspects(obj map[string]interface{}) error { + aspectsRaw, ok := obj["aspects"] + if !ok || aspectsRaw == nil { + return nil + } + + originalSlice, ok := aspectsRaw.([]interface{}) + if !ok { + return fmt.Errorf("TransformEntryLinkAspects: 'aspects' field is not a []interface{}, got %T", aspectsRaw) + } + + newMap := make(map[string]interface{}, len(originalSlice)) + for i, item := range originalSlice { + aspectMap, ok := item.(map[string]interface{}) + if !ok { + return fmt.Errorf("TransformEntryLinkAspects: item in 'aspects' slice at index %d is not a map[string]interface{}, got %T", i, item) + } + + keyRaw, keyExists := aspectMap["aspectKey"] + if !keyExists { + return fmt.Errorf("TransformEntryLinkAspects: 'aspectKey' not found in aspect item at index %d", i) + } + key, ok := keyRaw.(string) + if !ok { + return fmt.Errorf("TransformEntryLinkAspects: 'aspectKey' in item at index %d is not a string, got %T", i, keyRaw) + } + + valueRaw, valueExists := aspectMap["aspect"] + if !valueExists { + newMap[key] = map[string]interface{}{"data": map[string]interface{}{}} + continue + } + + value, ok := valueRaw.(map[string]interface{}) + if ok { + newMap[key] = value + } else { + newMap[key] = map[string]interface{}{"data": map[string]interface{}{}} + } + } + obj["aspects"] = newMap + return nil +} \ No newline at end of file diff --git a/mmv1/templates/terraform/custom_flatten/dataplex_entry_link_aspects.go.tmpl b/mmv1/templates/terraform/custom_flatten/dataplex_entry_link_aspects.go.tmpl new file mode 100644 index 000000000000..fe70172db3d9 --- /dev/null +++ b/mmv1/templates/terraform/custom_flatten/dataplex_entry_link_aspects.go.tmpl @@ -0,0 +1,44 @@ +// This file is a transposition of mmv1/templates/terraform/flatten_property_method.go.tmpl +// Most of the code is copied from there, with the exception of sorting logic. +func flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) interface{} { + if v == nil { + return v + } + l := v.([]interface{}) + transformed := make([]map[string]interface{}, 0, len(l)) + for _, raw := range l { + original := raw.(map[string]interface{}) + if len(original) < 1 { + // Do not include empty json objects coming back from the api + continue + } + transformed = append(transformed, map[string]interface{}{ + + {{- range $prop := $.ItemType.UserProperties }} + {{- if not (or $prop.IgnoreRead $prop.WriteOnlyLegacy $prop.WriteOnly) }} + "{{ underscore $prop.Name }}": flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}{{$prop.TitlelizeProperty}}(original["{{ $prop.ApiName }}"], d, config), + {{- end }} + {{- end }} + }) + } + + configData := []map[string]interface{}{} + + for _, item := range d.Get("aspects").([]interface{}) { + configData = append(configData, item.(map[string]interface{})) + } + + sorted, err := tpgresource.SortMapsByConfigOrder(configData, transformed, "aspect_key") + if err != nil { + log.Printf("[ERROR] Could not sort API response value: %s", err) + return v + } + + return sorted +} + +{{- if $.NestedProperties }} + {{- range $prop := $.NestedProperties }} + {{ template "flattenPropertyMethod" $prop -}} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/mmv1/templates/terraform/decoders/dataplex_entry_link.go.tmpl b/mmv1/templates/terraform/decoders/dataplex_entry_link.go.tmpl new file mode 100644 index 000000000000..9b8b9d27a0f7 --- /dev/null +++ b/mmv1/templates/terraform/decoders/dataplex_entry_link.go.tmpl @@ -0,0 +1,31 @@ +aspectKeysOfInterest := make(map[string]struct{}) +var err error + +if d.HasChange("aspects") { + currentAspects, futureAspects := d.GetChange("aspects") + err = AddEntryLinkAspectsToSet(aspectKeysOfInterest, currentAspects) + if err != nil { + return nil, err + } + err = AddEntryLinkAspectsToSet(aspectKeysOfInterest, futureAspects) + if err != nil { + return nil, err + } +} else { + err = AddEntryLinkAspectsToSet(aspectKeysOfInterest, d.Get("aspects")) + if err != nil { + return nil, err + } +} + +err = FilterEntryLinkAspects(aspectKeysOfInterest, res) +if err != nil { + return nil, err +} + +err = InverseTransformEntryLinkAspects(res) +if err != nil { + return nil, err +} + +return res, nil \ No newline at end of file diff --git a/mmv1/templates/terraform/encoders/dataplex_entry_link.go.tmpl b/mmv1/templates/terraform/encoders/dataplex_entry_link.go.tmpl new file mode 100644 index 000000000000..7a1e87737f36 --- /dev/null +++ b/mmv1/templates/terraform/encoders/dataplex_entry_link.go.tmpl @@ -0,0 +1,5 @@ +if err := TransformEntryLinkAspects(obj); err != nil { + return nil, err +} + +return obj, nil \ No newline at end of file diff --git a/mmv1/templates/terraform/examples/dataplex_entry_link_with_aspect.tf.tmpl b/mmv1/templates/terraform/examples/dataplex_entry_link_with_aspect.tf.tmpl new file mode 100644 index 000000000000..caa11d8114d0 --- /dev/null +++ b/mmv1/templates/terraform/examples/dataplex_entry_link_with_aspect.tf.tmpl @@ -0,0 +1,60 @@ +resource "google_bigquery_dataset" "bq_dataset" { + dataset_id = "tf_test_dataset_%{random_suffix}" + project = "{{index $.TestEnvVars "project_number"}}" + location = "us-central1" +} + +resource "google_bigquery_table" "table1" { + deletion_protection = false + dataset_id = google_bigquery_dataset.bq_dataset.dataset_id + table_id = "table1_%{random_suffix}" + project = "{{index $.TestEnvVars "project_number"}}" + schema = jsonencode([ + { + name = "col1" + type = "STRING" + mode = "NULLABLE" + description = "Column 1" + } + ]) +} + +resource "google_bigquery_table" "table2" { + deletion_protection = false + dataset_id = google_bigquery_dataset.bq_dataset.dataset_id + table_id = "table2_%{random_suffix}" + project = "{{index $.TestEnvVars "project_number"}}" + schema = jsonencode([ + { + name = "colA" + type = "STRING" + mode = "NULLABLE" + description = "Column A" + } + ]) +} + +resource "google_dataplex_entry_link" "full_entry_link_with_aspect" { + project = "{{index $.TestEnvVars "project_number"}}" + location = "us-central1" + entry_group_id = "@bigquery" + entry_link_id = "tf-test-full-entry-link%{random_suffix}" + entry_link_type = "projects/655216118709/locations/global/entryLinkTypes/schema-join" + entry_references { + name = "projects/%{project_number}/locations/us-central1/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/{{index $.TestEnvVars "project_id"}}/datasets/${google_bigquery_dataset.bq_dataset.dataset_id}/tables/${google_bigquery_table.table1.table_id}" + type = "" + } + entry_references { + name = "projects/%{project_number}/locations/us-central1/entryGroups/@bigquery/entries/bigquery.googleapis.com/projects/{{index $.TestEnvVars "project_id"}}/datasets/${google_bigquery_dataset.bq_dataset.dataset_id}/tables/${google_bigquery_table.table2.table_id}" + type = "" + } + aspects { + aspect_key = "655216118709.global.schema-join" + aspect { + data = jsonencode({ + joins = [] + userManaged = true + }) + } + } +} \ No newline at end of file diff --git a/mmv1/third_party/terraform/services/dataplex/resource_dataplex_entry_link_test.go b/mmv1/third_party/terraform/services/dataplex/resource_dataplex_entry_link_test.go index 0e59ba1291b4..b3a6cdd8d745 100644 --- a/mmv1/third_party/terraform/services/dataplex/resource_dataplex_entry_link_test.go +++ b/mmv1/third_party/terraform/services/dataplex/resource_dataplex_entry_link_test.go @@ -1,86 +1,520 @@ package dataplex_test import ( + "reflect" + "strings" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" - + "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-provider-google/google/acctest" "github.com/hashicorp/terraform-provider-google/google/envvar" + dataplex "github.com/hashicorp/terraform-provider-google/google/services/dataplex" ) +func TestEntryLinkProjectNumberValidation(t *testing.T) { + fieldName := "some_field" + testCases := []struct { + name string + input interface{} + expectError bool + errorMsg string + }{ + {"valid input", "projects/1234567890/locations/us-central1", false, ""}, + {"valid input with only number", "projects/987/stuff", false, ""}, + {"valid input with trailing slash content", "projects/1/a/b/c", false, ""}, + {"valid input minimal", "projects/1/a", false, ""}, + {"invalid input trailing slash only", "projects/555/", true, "has an invalid format"}, + {"invalid type - int", 123, true, `to be string, but got int`}, + {"invalid type - nil", nil, true, `to be string, but got `}, + {"invalid format - missing 'projects/' prefix", "12345/locations/us", true, "has an invalid format"}, + {"invalid format - project number starts with 0", "projects/0123/data", true, "has an invalid format"}, + {"invalid format - no project number", "projects//data", true, "has an invalid format"}, + {"invalid format - letters instead of number", "projects/abc/data", true, "has an invalid format"}, + {"invalid format - missing content after number/", "projects/123", true, "has an invalid format"}, + {"invalid format - empty string", "", true, "has an invalid format"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, errors := dataplex.EntryLinkProjectNumberValidation(tc.input, fieldName) + hasError := len(errors) > 0 + + if hasError != tc.expectError { + t.Fatalf("%s: EntryLinkProjectNumberValidation() error expectation mismatch: got error = %v (%v), want error = %v", tc.name, hasError, errors, tc.expectError) + } + + if tc.expectError && tc.errorMsg != "" { + found := false + for _, err := range errors { + if strings.Contains(err.Error(), tc.errorMsg) { // Check if error message contains the expected substring + found = true + break + } + } + if !found { + t.Errorf("%s: EntryLinkProjectNumberValidation() expected error containing %q, but got: %v", tc.name, tc.errorMsg, errors) + } + } + }) + } +} + +func TestFilterEntryLinkAspects(t *testing.T) { + testCases := []struct { + name string + aspectKeySet map[string]struct{} + resInput map[string]interface{} + expectedAspects map[string]interface{} + expectError bool + errorMsg string + }{ + {"aspects key is absent", map[string]struct{}{"keep": {}}, map[string]interface{}{"otherKey": "value"}, nil, false, ""}, + {"aspects value is nil", map[string]struct{}{"keep": {}}, map[string]interface{}{"aspects": nil}, nil, false, ""}, + {"empty aspectKeySet", map[string]struct{}{}, map[string]interface{}{"aspects": map[string]interface{}{"one": map[string]interface{}{"data": 1}, "two": map[string]interface{}{"data": 2}}}, map[string]interface{}{}, false, ""}, + {"keep all aspects", map[string]struct{}{"one": {}, "two": {}}, map[string]interface{}{"aspects": map[string]interface{}{"one": map[string]interface{}{"data": 1}, "two": map[string]interface{}{"data": 2}}}, map[string]interface{}{"one": map[string]interface{}{"data": 1}, "two": map[string]interface{}{"data": 2}}, false, ""}, + {"keep some aspects", map[string]struct{}{"two": {}, "three_not_present": {}}, map[string]interface{}{"aspects": map[string]interface{}{"one": map[string]interface{}{"data": 1}, "two": map[string]interface{}{"data": 2}}}, map[string]interface{}{"two": map[string]interface{}{"data": 2}}, false, ""}, + {"input aspects map is empty", map[string]struct{}{"keep": {}}, map[string]interface{}{"aspects": map[string]interface{}{}}, map[string]interface{}{}, false, ""}, + {"aspects is wrong type", map[string]struct{}{"keep": {}}, map[string]interface{}{"aspects": "not a map"}, nil, true, "FilterEntryLinkAspects: 'aspects' field is not a map[string]interface{}, got string"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resCopy := deepCopyMap(tc.resInput) + originalAspectsBeforeCall := deepCopyValue(resCopy["aspects"]) + + err := dataplex.FilterEntryLinkAspects(tc.aspectKeySet, resCopy) + + if tc.expectError { + if err == nil { + t.Fatalf("%s: Expected an error, but got nil", tc.name) + } + if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("%s: Expected error message containing %q, got %q", tc.name, tc.errorMsg, err.Error()) + } + if !reflect.DeepEqual(resCopy["aspects"], originalAspectsBeforeCall) { + t.Errorf("%s: resCopy['aspects'] was modified during error case.\nBefore: %#v\nAfter: %#v", tc.name, originalAspectsBeforeCall, resCopy["aspects"]) + } + return + } + + if err != nil { + t.Fatalf("%s: Did not expect an error, but got: %v", tc.name, err) + } + + actualAspectsRaw, aspectsKeyExists := resCopy["aspects"] + + if tc.expectedAspects == nil { + if aspectsKeyExists && actualAspectsRaw != nil { + if tc.name == "aspects key is absent" { + if aspectsKeyExists { + t.Errorf("%s: Expected 'aspects' key to be absent, but it exists with value: %v", tc.name, actualAspectsRaw) + } + } else { + t.Errorf("%s: Expected 'aspects' value to be nil, but got: %v", tc.name, actualAspectsRaw) + } + } + return + } + + if !aspectsKeyExists { + t.Fatalf("%s: Expected 'aspects' key to exist, but it was absent. Expected value: %#v", tc.name, tc.expectedAspects) + } + + actualAspects, ok := actualAspectsRaw.(map[string]interface{}) + if !ok { + t.Fatalf("%s: Expected 'aspects' to be a map[string]interface{}, but got %T. Value: %#v", tc.name, actualAspectsRaw, actualAspectsRaw) + } + + if !reflect.DeepEqual(actualAspects, tc.expectedAspects) { + t.Errorf("%s: FilterEntryLinkAspects() result mismatch:\ngot: %#v\nwant: %#v", tc.name, actualAspects, tc.expectedAspects) + } + }) + } +} + +func TestAddEntryLinkAspectsToSet(t *testing.T) { + testCases := []struct { + name string + initialSet map[string]struct{} + aspectsInput interface{} + expectedSet map[string]struct{} + expectError bool + errorMsg string + }{ + {"add to empty set", map[string]struct{}{}, []interface{}{map[string]interface{}{"aspect_key": "key1"}, map[string]interface{}{"aspect_key": "key2"}}, map[string]struct{}{"key1": {}, "key2": {}}, false, ""}, + {"add to existing set", map[string]struct{}{"existing": {}}, []interface{}{map[string]interface{}{"aspect_key": "key1"}}, map[string]struct{}{"existing": {}, "key1": {}}, false, ""}, + {"add duplicate keys", map[string]struct{}{}, []interface{}{map[string]interface{}{"aspect_key": "key1"}, map[string]interface{}{"aspect_key": "key1"}, map[string]interface{}{"aspect_key": "key2"}}, map[string]struct{}{"key1": {}, "key2": {}}, false, ""}, + {"input aspects is empty slice", map[string]struct{}{"existing": {}}, []interface{}{}, map[string]struct{}{"existing": {}}, false, ""}, + {"input aspects is nil", map[string]struct{}{"original": {}}, nil, map[string]struct{}{"original": {}}, false, ""}, + {"input aspects is wrong type", map[string]struct{}{}, "not a slice", map[string]struct{}{}, true, "AddEntryLinkAspectsToSet: input 'aspects' is not a []interface{}, got string"}, + {"item in slice is not a map", map[string]struct{}{}, []interface{}{"not a map"}, map[string]struct{}{}, true, "AddEntryLinkAspectsToSet: item at index 0 is not a map[string]interface{}, got string"}, + {"item map missing aspect_key", map[string]struct{}{}, []interface{}{map[string]interface{}{"wrong_key": "key1"}}, map[string]struct{}{}, true, "AddEntryLinkAspectsToSet: 'aspect_key' not found in aspect item at index 0"}, + {"aspect_key is not a string", map[string]struct{}{}, []interface{}{map[string]interface{}{"aspect_key": 123}}, map[string]struct{}{}, true, "AddEntryLinkAspectsToSet: 'aspect_key' in item at index 0 is not a string, got int"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + currentSet := make(map[string]struct{}) + for k, v := range tc.initialSet { + currentSet[k] = v + } + + err := dataplex.AddEntryLinkAspectsToSet(currentSet, tc.aspectsInput) + + if tc.expectError { + if err == nil { + t.Fatalf("%s: Expected an error, but got nil", tc.name) + } + if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("%s: Expected error message containing %q, got %q", tc.name, tc.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Fatalf("%s: Did not expect an error, but got: %v", tc.name, err) + } + if !reflect.DeepEqual(currentSet, tc.expectedSet) { + t.Errorf("%s: AddEntryLinkAspectsToSet() result mismatch:\ngot: %v\nwant: %v", tc.name, currentSet, tc.expectedSet) + } + } + }) + } +} + +func TestInverseTransformEntryLinkAspects(t *testing.T) { + testCases := []struct { + name string + resInput map[string]interface{} + expectedAspects []interface{} + expectNilAspects bool + expectError bool + errorMsg string + }{ + {"aspects key is absent", map[string]interface{}{"otherKey": "value"}, nil, true, false, ""}, + {"aspects value is nil", map[string]interface{}{"aspects": nil}, nil, true, false, ""}, + {"aspects is empty map", map[string]interface{}{"aspects": map[string]interface{}{}}, []interface{}{}, false, false, ""}, + {"aspects with one entry", map[string]interface{}{"aspects": map[string]interface{}{"key1": map[string]interface{}{"data": "value1"}}}, []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value1"}}}, false, false, ""}, + {"aspects with multiple entries", map[string]interface{}{"aspects": map[string]interface{}{"key2": map[string]interface{}{"data": "value2"}, "key1": map[string]interface{}{"data": "value1"}}}, []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value1"}}, map[string]interface{}{"aspectKey": "key2", "aspect": map[string]interface{}{"data": "value2"}}}, false, false, ""}, + {"aspects is wrong type (not map)", map[string]interface{}{"aspects": "not a map"}, nil, false, true, "InverseTransformEntryLinkAspects: 'aspects' field is not a map[string]interface{}, got string"}, + {"aspect value is not a map", map[string]interface{}{"aspects": map[string]interface{}{"key1": "not a map value"}}, nil, false, true, "InverseTransformEntryLinkAspects: value for key 'key1' is not a map[string]interface{}, got string"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resCopy := deepCopyMap(tc.resInput) + originalAspectsBeforeCall := deepCopyValue(resCopy["aspects"]) + + err := dataplex.InverseTransformEntryLinkAspects(resCopy) + + if tc.expectError { + if err == nil { + t.Fatalf("%s: Expected an error, but got nil", tc.name) + } + if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("%s: Expected error message containing %q, got %q", tc.name, tc.errorMsg, err.Error()) + } + if !reflect.DeepEqual(resCopy["aspects"], originalAspectsBeforeCall) { + t.Errorf("%s: resCopy['aspects'] was modified during error case.\nBefore: %#v\nAfter: %#v", tc.name, originalAspectsBeforeCall, resCopy["aspects"]) + } + return + } + + if err != nil { + t.Fatalf("%s: Did not expect an error, but got: %v", tc.name, err) + } + + actualAspectsRaw, aspectsKeyExists := resCopy["aspects"] + + if tc.expectNilAspects { + if aspectsKeyExists && actualAspectsRaw != nil { + t.Errorf("%s: Expected 'aspects' to be nil or absent, but got: %#v", tc.name, actualAspectsRaw) + } + return + } + + if !aspectsKeyExists { + t.Fatalf("%s: Expected 'aspects' key in result map, but it was missing. Expected value: %#v", tc.name, tc.expectedAspects) + } + if actualAspectsRaw == nil && tc.expectedAspects != nil { + t.Fatalf("%s: Expected 'aspects' to be non-nil, but got nil. Expected value: %#v", tc.name, tc.expectedAspects) + } + + actualAspectsSlice, ok := actualAspectsRaw.([]interface{}) + if !ok { + if tc.expectedAspects != nil || actualAspectsRaw != nil { + t.Fatalf("%s: Expected 'aspects' to be []interface{}, but got %T. Value: %#v", tc.name, actualAspectsRaw, actualAspectsRaw) + } + } + + if actualAspectsSlice != nil { + sortAspectSlice(actualAspectsSlice) + } + if tc.expectedAspects != nil { + sortAspectSlice(tc.expectedAspects) + } + + if !reflect.DeepEqual(actualAspectsSlice, tc.expectedAspects) { + t.Errorf("%s: InverseTransformEntryLinkAspects() result mismatch:\ngot: %#v\nwant: %#v", tc.name, actualAspectsSlice, tc.expectedAspects) + } + }) + } +} + +func TestTransformEntryLinkAspects(t *testing.T) { + testCases := []struct { + name string + objInput map[string]interface{} + expectedAspects map[string]interface{} + expectNilAspects bool + expectError bool + errorMsg string + }{ + {"aspects key is absent", map[string]interface{}{"otherKey": "value"}, nil, true, false, ""}, + {"aspects value is nil", map[string]interface{}{"aspects": nil}, nil, true, false, ""}, + {"aspects is empty slice", map[string]interface{}{"aspects": []interface{}{}}, map[string]interface{}{}, false, false, ""}, + {"aspects with one item", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value1"}}}}, map[string]interface{}{"key1": map[string]interface{}{"data": "value1"}}, false, false, ""}, + {"aspects with one item that has no aspect", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1"}}}, map[string]interface{}{"key1": map[string]interface{}{"data": map[string]interface{}{}}}, false, false, ""}, + {"aspects with multiple items", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value1"}}, map[string]interface{}{"aspectKey": "key2", "aspect": map[string]interface{}{"data": "value2"}}}}, map[string]interface{}{"key1": map[string]interface{}{"data": "value1"}, "key2": map[string]interface{}{"data": "value2"}}, false, false, ""}, + {"aspects with duplicate aspectKey", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value_first"}}, map[string]interface{}{"aspectKey": "key2", "aspect": map[string]interface{}{"data": "value2"}}, map[string]interface{}{"aspectKey": "key1", "aspect": map[string]interface{}{"data": "value_last"}}}}, map[string]interface{}{"key1": map[string]interface{}{"data": "value_last"}, "key2": map[string]interface{}{"data": "value2"}}, false, false, ""}, + {"aspects is wrong type (not slice)", map[string]interface{}{"aspects": "not a slice"}, nil, false, true, "TransformEntryLinkAspects: 'aspects' field is not a []interface{}, got string"}, + {"item in slice is not a map", map[string]interface{}{"aspects": []interface{}{"not a map"}}, nil, false, true, "TransformEntryLinkAspects: item in 'aspects' slice at index 0 is not a map[string]interface{}, got string"}, + {"item map missing aspectKey", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"wrongKey": "k1", "aspect": map[string]interface{}{}}}}, nil, false, true, "TransformEntryLinkAspects: 'aspectKey' not found in aspect item at index 0"}, + {"aspectKey is not a string", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": 123, "aspect": map[string]interface{}{}}}}, nil, false, true, "TransformEntryLinkAspects: 'aspectKey' in item at index 0 is not a string, got int"}, + {"aspect is present but wrong type", map[string]interface{}{"aspects": []interface{}{map[string]interface{}{"aspectKey": "key1", "aspect": "not a map"}}}, map[string]interface{}{"key1": map[string]interface{}{"data": map[string]interface{}{}}}, false, false, ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + objCopy := deepCopyMap(tc.objInput) + originalAspectsBeforeCall := deepCopyValue(objCopy["aspects"]) + + err := dataplex.TransformEntryLinkAspects(objCopy) + + if tc.expectError { + if err == nil { + t.Fatalf("%s: Expected an error, but got nil", tc.name) + } + if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("%s: Expected error message containing %q, got %q", tc.name, tc.errorMsg, err.Error()) + } + if !reflect.DeepEqual(objCopy["aspects"], originalAspectsBeforeCall) { + t.Errorf("%s: objCopy['aspects'] was modified during error case.\nBefore: %#v\nAfter: %#v", tc.name, originalAspectsBeforeCall, objCopy["aspects"]) + } + return + } + + if err != nil { + t.Fatalf("%s: Did not expect an error, but got: %v", tc.name, err) + } + + actualAspectsRaw, aspectsKeyExists := objCopy["aspects"] + + if tc.expectNilAspects { + if aspectsKeyExists && actualAspectsRaw != nil { + t.Errorf("%s: Expected 'aspects' to be nil or absent, but got: %#v", tc.name, actualAspectsRaw) + } + return + } + + if !aspectsKeyExists { + t.Fatalf("%s: Expected 'aspects' key in result map, but it was missing. Expected value: %#v", tc.name, tc.expectedAspects) + } + if actualAspectsRaw == nil && tc.expectedAspects != nil { + t.Fatalf("%s: Expected 'aspects' to be non-nil, but got nil. Expected value: %#v", tc.name, tc.expectedAspects) + } + + actualAspectsMap, ok := actualAspectsRaw.(map[string]interface{}) + if !ok { + if tc.expectedAspects != nil || actualAspectsRaw != nil { + t.Fatalf("%s: Expected 'aspects' to be map[string]interface{}, but got %T. Value: %#v", tc.name, actualAspectsRaw, actualAspectsRaw) + } + } + + if !reflect.DeepEqual(actualAspectsMap, tc.expectedAspects) { + t.Errorf("%s: TransformEntryLinkAspects() result mismatch:\ngot: %#v\nwant: %#v", tc.name, actualAspectsMap, tc.expectedAspects) + } + }) + } +} + func TestAccDataplexEntryLink_update(t *testing.T) { t.Parallel() context := map[string]interface{}{ "project_number": envvar.GetTestProjectNumberFromEnv(), + "project_id": envvar.GetTestProjectFromEnv(), "random_suffix": acctest.RandString(t, 10), } acctest.VcrTest(t, resource.TestCase{ PreCheck: func() { acctest.AccTestPreCheck(t) }, ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckDataplexEntryLinkDestroyProducer(t), Steps: []resource.TestStep{ { - Config: testAccDataplexEntryLink_dataplexEntryLinkUpdate(context), + Config: testAccDataplexEntryLink_updatePrepare(context), + }, + { + ResourceName: "google_dataplex_entry_link.full_entry_link_with_aspect", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"aspects", "dataset_id", "table_id", "entry_link_id", "location"}, + }, + + { + Config: testAccDataplexEntryLink_update(context), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("google_dataplex_entry_link.full_entry_link_with_aspect", plancheck.ResourceActionUpdate), + }, + }, }, { - ResourceName: "google_dataplex_entry_link.basic_entry_link", + ResourceName: "google_dataplex_entry_link.full_entry_link_with_aspect", ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"entry_group_id", "entry_link_id", "location"}, + ImportStateVerifyIgnore: []string{"aspects", "dataset_id", "table_id", "entry_link_id", "location"}, }, }, }) } -func testAccDataplexEntryLink_dataplexEntryLinkUpdate(context map[string]interface{}) string { +func testAccDataplexEntryLink_updatePrepare(context map[string]interface{}) string { return acctest.Nprintf(` -resource "google_dataplex_entry_group" "entry-group-basic" { - location = "us-central1" - entry_group_id = "tf-test-entry-group%{random_suffix}" - project = "%{project_number}" + +resource "google_bigquery_dataset" "bq_dataset" { + dataset_id = "tf_test_dataset_%{random_suffix}" + project = "%{project_number}" + location = "us-central1" } -resource "google_dataplex_entry" "source" { - location = "us-central1" - entry_group_id = google_dataplex_entry_group.entry-group-basic.entry_group_id - entry_id = "tf-test-source-entry%{random_suffix}" - entry_type = google_dataplex_entry_type.entry-type-basic.name - project = "%{project_number}" + +resource "google_bigquery_table" "table1" { + deletion_protection = false + dataset_id = google_bigquery_dataset.bq_dataset.dataset_id + table_id = "table1_%{random_suffix}" + project = "%{project_number}" + schema = <