diff --git a/mmv1/products/firestore/Field.yaml b/mmv1/products/firestore/Field.yaml new file mode 100644 index 000000000000..0b02c5abb140 --- /dev/null +++ b/mmv1/products/firestore/Field.yaml @@ -0,0 +1,166 @@ +# Copyright 2023 Google Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- !ruby/object:Api::Resource +name: 'Field' +base_url: projects/{{project}}/databases/{{database}}/collectionGroups/{{collection}}/fields +create_url: projects/{{project}}/databases/{{database}}/collectionGroups/{{collection}}/fields/{{field}} +self_link: '{{name}}' +immutable: false +update_verb: :PATCH +update_mask: true +create_verb: :PATCH +description: | + Represents a single field in the database. + Fields are grouped by their "Collection Group", which represent all collections + in the database with the same id. +references: !ruby/object:Api::Resource::ReferenceLinks + guides: + 'Official Documentation': 'https://cloud.google.com/firestore/docs/query-data/indexing' + api: 'https://cloud.google.com/firestore/docs/reference/rest/v1/projects.databases.collectionGroups.fields' +async: !ruby/object:Api::OpAsync + operation: !ruby/object:Api::OpAsync::Operation + path: 'name' + base_url: '{{op_id}}' + wait_ms: 1000 + result: !ruby/object:Api::OpAsync::Result + path: 'response' + resource_inside_response: true + status: !ruby/object:Api::OpAsync::Status + path: 'done' + complete: true + allowed: + - true + - false + error: !ruby/object:Api::OpAsync::Error + path: 'error' + message: 'message' +autogen_async: true +skip_sweeper: true +import_format: ["{{name}}"] +docs: !ruby/object:Provider::Terraform::Docs + warning: | + This resource creates a Firestore Single Field override on a project that + already has a Firestore database. If you haven't already created it, you may + create a `google_firestore_database` resource with `location_id` set to your + chosen location. +examples: + - !ruby/object:Provider::Terraform::Examples + name: "firestore_field_basic" + primary_resource_id: "basic" + test_env_vars: + project_id: :FIRESTORE_PROJECT_NAME + - !ruby/object:Provider::Terraform::Examples + name: "firestore_field_timestamp" + primary_resource_id: "timestamp" + test_env_vars: + project_id: :FIRESTORE_PROJECT_NAME + - !ruby/object:Provider::Terraform::Examples + name: "firestore_field_match_override" + primary_resource_id: "match_override" + test_env_vars: + project_id: :FIRESTORE_PROJECT_NAME +custom_code: !ruby/object:Provider::Terraform::CustomCode + custom_import: templates/terraform/custom_import/index_self_link_as_name_set_project.go.erb + encoder: templates/terraform/encoders/firestore_field.go.erb + custom_delete: templates/terraform/custom_delete/firestore_field_delete.go.erb + test_check_destroy: templates/terraform/custom_check_destroy/firestore_field.go.erb +properties: + - !ruby/object:Api::Type::String + name: 'database' + default_value: '(default)' + description: | + The Firestore database id. Defaults to `"(default)"`. + url_param_only: true + - !ruby/object:Api::Type::String + name: 'collection' + description: | + The id of the collection group to configure. + required: true + url_param_only: true + - !ruby/object:Api::Type::String + name: 'field' + description: | + The id of the field to configure. + required: true + url_param_only: true + - !ruby/object:Api::Type::String + name: name + output: true + description: | + The name of this field. Format: + `projects/{{project}}/databases/{{database}}/collectionGroups/{{collection}}/fields/{{field}}` + - !ruby/object:Api::Type::NestedObject + name: indexConfig + description: | + The single field index configuration for this field. + Creating an index configuration for this field will override any inherited configuration with the + indexes specified. Configuring the index configuration with an empty block disables all indexes on + the field. + send_empty_value: true + custom_expand: templates/terraform/custom_expand/firestore_field_index_config.go.erb + custom_flatten: templates/terraform/custom_flatten/firestore_field_index_config.go.erb + properties: + - !ruby/object:Api::Type::Array + name: indexes + description: The indexes to configure on the field. Order or array contains must be specified. + is_set: true + item_type: !ruby/object:Api::Type::NestedObject + properties: + - !ruby/object:Api::Type::Enum + name: queryScope + description: | + The scope at which a query is run. Collection scoped queries require you specify + the collection at query time. Collection group scope allows queries across all + collections with the same id. + default_value: :COLLECTION + values: + - :COLLECTION + - :COLLECTION_GROUP + - !ruby/object:Api::Type::Enum + name: 'order' + description: | + Indicates that this field supports ordering by the specified order or comparing using =, <, <=, >, >=, !=. + Only one of `order` and `arrayConfig` can be specified. + values: + - :ASCENDING + - :DESCENDING + exactly_one_of: + - order + - arrayConfig + - !ruby/object:Api::Type::Enum + name: 'arrayConfig' + description: | + Indicates that this field supports operations on arrayValues. Only one of `order` and `arrayConfig` can + be specified. + values: + - :CONTAINS + exactly_one_of: + - order + - arrayConfig + - !ruby/object:Api::Type::NestedObject + name: ttlConfig + custom_expand: templates/terraform/custom_expand/empty_object_if_set.go.erb + description: | + If set, this field is configured for TTL deletion. + send_empty_value: true + properties: + - !ruby/object:Api::Type::Enum + name: state + description: | + The state of the TTL configuration. + output: true + values: + - :CREATING + - :ACTIVE + - :NEEDS_REPAIR \ No newline at end of file diff --git a/mmv1/templates/terraform/custom_check_destroy/firestore_field.go.erb b/mmv1/templates/terraform/custom_check_destroy/firestore_field.go.erb new file mode 100644 index 000000000000..7ba2e3ae5b48 --- /dev/null +++ b/mmv1/templates/terraform/custom_check_destroy/firestore_field.go.erb @@ -0,0 +1,29 @@ +// Firestore fields are not deletable. We consider the field deleted if: +// 1) the index configuration has no overrides and matches the ancestor configuration. +// 2) the ttl configuration is unset. + +config := GoogleProviderConfig(t) + +url, err := replaceVarsForTest(config, rs, "{{FirestoreBasePath}}projects/{{project}}/databases/{{database}}/collectionGroups/{{collection}}/fields/{{field}}") +if err != nil { + return err +} + +res, err := SendRequest(config, "GET", "", url, config.UserAgent, nil) +if err != nil { + return err +} + +if v := res["indexConfig"]; v != nil { + indexConfig := v.(map[string]interface{}) + + usesAncestorConfig, ok := indexConfig["usesAncestorConfig"].(bool) + + if !ok || !usesAncestorConfig { + return fmt.Errorf("Index configuration is not using the ancestor config %s.", url) + } +} + +if res["ttlConfig"] != nil { + return fmt.Errorf("TTL configuration was not deleted at %s.", url) +} \ No newline at end of file diff --git a/mmv1/templates/terraform/custom_delete/firestore_field_delete.go.erb b/mmv1/templates/terraform/custom_delete/firestore_field_delete.go.erb new file mode 100644 index 000000000000..cbea55cb5861 --- /dev/null +++ b/mmv1/templates/terraform/custom_delete/firestore_field_delete.go.erb @@ -0,0 +1,48 @@ +// Firestore fields cannot be deleted, instead we clear the indexConfig and ttlConfig. + +log.Printf("[DEBUG] Deleting Field %q", d.Id()) + +billingProject := "" + +project, err := getProject(d, config) +if err != nil { + return fmt.Errorf("Error fetching project for App: %s", err) +} +billingProject = project + +url, err := ReplaceVars(d, config, "{{FirestoreBasePath}}{{name}}") +if err != nil { + return err +} + +updateMask := []string{"indexConfig", "ttlConfig"} + +url, err = AddQueryParams(url, map[string]string{"updateMask": strings.Join(updateMask, ",")}) +if err != nil { + return err +} + + +// err == nil indicates that the billing_project value was found +if bp, err := getBillingProject(d, config); err == nil { + billingProject = bp +} + +// Clear fields by sending an empty PATCH request with appropriate update mask. +req := make(map[string]interface{}) +res, err := SendRequestWithTimeout(config, "PATCH", billingProject, url, userAgent, req, d.Timeout(schema.TimeoutUpdate)) + +if err != nil { + return fmt.Errorf("Error deleting Field %q: %s", d.Id(), err) +} + +err = FirestoreOperationWaitTime( + config, res, project, "Deleting Field", userAgent, + d.Timeout(schema.TimeoutDelete)) + +if err != nil { + return err +} + +log.Printf("[DEBUG] Finished deleting Field %q", d.Id()) +return nil \ No newline at end of file diff --git a/mmv1/templates/terraform/custom_expand/empty_object_if_set.go.erb b/mmv1/templates/terraform/custom_expand/empty_object_if_set.go.erb new file mode 100644 index 000000000000..49f29d58bf29 --- /dev/null +++ b/mmv1/templates/terraform/custom_expand/empty_object_if_set.go.erb @@ -0,0 +1,20 @@ +/* + * Expands an empty terraform config into an empty object. + * + * Used to differentate a user specifying an empty block versus a null/unset block. + * + * This is unique from send_empty_value, which will send an explicit null value + * for empty configuration blocks. + */ +func expand<%= prefix -%><%= titlelize_property(property) -%>(v interface{}, d TerraformResourceData, config *transport_tpg.Config) (interface{}, error) { + if v == nil { + return nil, nil + } + + l := v.([]interface{}) + if len(l) == 0 { + return nil, nil + } + // A set, but empty object. + return struct{}{}, nil +} \ No newline at end of file diff --git a/mmv1/templates/terraform/custom_expand/firestore_field_index_config.go.erb b/mmv1/templates/terraform/custom_expand/firestore_field_index_config.go.erb new file mode 100644 index 000000000000..f7e59d0c2e8b --- /dev/null +++ b/mmv1/templates/terraform/custom_expand/firestore_field_index_config.go.erb @@ -0,0 +1,52 @@ +func expand<%= prefix -%><%= titlelize_property(property) -%>(v interface{}, d TerraformResourceData, config *transport_tpg.Config) (interface{}, error) { + // We drop all output only fields as they are unnecessary. + if v == nil { + return nil, nil + } + + l := v.([]interface{}) + if len(l) == 0 { + return nil, nil + } + + transformedIndexConfig := make(map[string]interface{}) + + // A configured, but empty, index_config block should be sent. This is how a user would remove all indexes. + if l[0] == nil { + return transformedIndexConfig, nil + } + + indexConfig := l[0].(map[string]interface{}) + + // For Single field indexes, we put the field configuration on the index to avoid forced nesting. + // Push all order/arrayConfig down into a single element fields list. + l = indexConfig["indexes"].(*schema.Set).List() + transformedIndexes := make([]interface{}, 0, len(l)) + for _, raw := range l { + if raw == nil { + continue + } + original := raw.(map[string]interface{}) + transformed := make(map[string]interface{}) + transformedField := make(map[string]interface{}) + + if val := reflect.ValueOf(original["query_scope"]); val.IsValid() && !isEmptyValue(val) { + transformed["queryScope"] = original["query_scope"] + } + + if val := reflect.ValueOf(original["order"]); val.IsValid() && !isEmptyValue(val) { + transformedField["order"] = original["order"] + } + + if val := reflect.ValueOf(original["array_config"]); val.IsValid() && !isEmptyValue(val) { + transformedField["arrayConfig"] = original["array_config"] + } + transformed["fields"] = [1]interface{}{ + transformedField, + } + + transformedIndexes = append(transformedIndexes, transformed) + } + transformedIndexConfig["indexes"] = transformedIndexes + return transformedIndexConfig, nil +} \ No newline at end of file diff --git a/mmv1/templates/terraform/custom_flatten/firestore_field_index_config.go.erb b/mmv1/templates/terraform/custom_flatten/firestore_field_index_config.go.erb new file mode 100644 index 000000000000..eb14a63c2735 --- /dev/null +++ b/mmv1/templates/terraform/custom_flatten/firestore_field_index_config.go.erb @@ -0,0 +1,42 @@ +func flatten<%= prefix -%><%= titlelize_property(property) -%>(v interface{}, d TerraformResourceData, config *transport_tpg.Config) (interface{}) { + if v == nil { + return v + } + indexConfig := v.(map[string]interface{}) + + usesAncestorConfig := false + if indexConfig["usesAncestorConfig"] != nil { + usesAncestorConfig = indexConfig["usesAncestorConfig"].(bool) + } + + if usesAncestorConfig { + // The intent when uses_ancestor_config is no config. + return []interface{}{} + } + + if indexConfig["indexes"] == nil { + // No indexes, return an existing, but empty index config. + return [1]interface{}{nil} + } + + // For Single field indexes, we put the field configuration on the index to avoid forced nesting. + l := indexConfig["indexes"].([]interface{}) + transformed := make(map[string]interface{}) + transformedIndexes := make([]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 + } + fields := original["fields"].([]interface{}) + sfi := fields[0].(map[string]interface{}) + transformedIndexes = append(transformedIndexes, map[string]interface{}{ + "query_scope": original["queryScope"], + "order": sfi["order"], + "array_config": sfi["arrayConfig"], + }) + } + transformed["indexes"] = transformedIndexes + return []interface{}{transformed} +} diff --git a/mmv1/templates/terraform/custom_import/firestore_field.go.erb b/mmv1/templates/terraform/custom_import/firestore_field.go.erb new file mode 100644 index 000000000000..1ccd66835994 --- /dev/null +++ b/mmv1/templates/terraform/custom_import/firestore_field.go.erb @@ -0,0 +1,29 @@ + +config := meta.(*transport_tpg.Config) + +// current import_formats can't import fields with forward slashes in their value +if err := ParseImportId([]string{"(?P.+)"}, d, config); err != nil { + return nil, err +} + +// Re-populate split fields from the name. +re := regexp.MustCompile("^projects/([^/]+)/databases/([^/]+)/collectionGroups/([^/]+)/fields/(.+)$") +match := re.FindStringSubmatch(d.Get("name").(string)) +if len(match) > 0 { + if err := d.Set("project", match[1]); err != nil { + return nil, fmt.Errorf("Error setting project: %s", err) + } + if err := d.Set("database", match[2]); err != nil { + return nil, fmt.Errorf("Error setting database: %s", err) + } + if err := d.Set("collection", match[3]); err != nil { + return nil, fmt.Errorf("Error setting collection: %s", err) + } + if err := d.Set("field", match[4]); err != nil { + return nil, fmt.Errorf("Error setting field: %s", err) + } +} else { + return nil, fmt.Errorf("import did not match the regex ^projects/([^/]+)/databases/([^/]+)/collectionGroups/([^/]+)/fields/(.+)$") +} + +return []*schema.ResourceData{d}, nil diff --git a/mmv1/templates/terraform/encoders/firestore_field.go.erb b/mmv1/templates/terraform/encoders/firestore_field.go.erb new file mode 100644 index 000000000000..96383af29f6e --- /dev/null +++ b/mmv1/templates/terraform/encoders/firestore_field.go.erb @@ -0,0 +1,9 @@ + +// We've added project / database / collection / field as split fields of the name, but +// the API doesn't expect them. Make sure we remove them from any requests. + +delete(obj, "project") +delete(obj, "database") +delete(obj, "collection") +delete(obj, "field") +return obj, nil diff --git a/mmv1/templates/terraform/examples/firestore_field_basic.tf.erb b/mmv1/templates/terraform/examples/firestore_field_basic.tf.erb new file mode 100644 index 000000000000..71b23926b04f --- /dev/null +++ b/mmv1/templates/terraform/examples/firestore_field_basic.tf.erb @@ -0,0 +1,18 @@ +resource "google_firestore_field" "<%= ctx[:primary_resource_id] %>" { + project = "<%= ctx[:test_env_vars]['project_id'] %>" + database = "(default)" + collection = "chatrooms_%{random_suffix}" + field = "basic" + + index_config { + indexes { + order = "ASCENDING" + query_scope = "COLLECTION_GROUP" + } + indexes { + array_config = "CONTAINS" + } + } + + ttl_config {} +} diff --git a/mmv1/templates/terraform/examples/firestore_field_complex_field_name.tf.erb b/mmv1/templates/terraform/examples/firestore_field_complex_field_name.tf.erb new file mode 100644 index 000000000000..8f0c7ccb1688 --- /dev/null +++ b/mmv1/templates/terraform/examples/firestore_field_complex_field_name.tf.erb @@ -0,0 +1,16 @@ +resource "google_firestore_field" "<%= ctx[:primary_resource_id] %>" { + project = "<%= ctx[:test_env_vars]['project_id'] %>" + collection = "chatrooms_%{random_suffix}" + field = "`*`" + + index_config { + indexes { + order = "ASCENDING" + query_scope = "COLLECTION_GROUP" + } + indexes { + array_config = "CONTAINS" + } + } + ttl_config {} +} diff --git a/mmv1/templates/terraform/examples/firestore_field_match_override.tf.erb b/mmv1/templates/terraform/examples/firestore_field_match_override.tf.erb new file mode 100644 index 000000000000..cebafc386f33 --- /dev/null +++ b/mmv1/templates/terraform/examples/firestore_field_match_override.tf.erb @@ -0,0 +1,17 @@ +resource "google_firestore_field" "<%= ctx[:primary_resource_id] %>" { + project = "<%= ctx[:test_env_vars]['project_id'] %>" + collection = "chatrooms_%{random_suffix}" + field = "field_with_same_configuration_as_ancestor" + + index_config { + indexes { + order = "ASCENDING" + } + indexes { + order = "DESCENDING" + } + indexes { + array_config = "CONTAINS" + } + } +} \ No newline at end of file diff --git a/mmv1/templates/terraform/examples/firestore_field_timestamp.tf.erb b/mmv1/templates/terraform/examples/firestore_field_timestamp.tf.erb new file mode 100644 index 000000000000..6defbde35498 --- /dev/null +++ b/mmv1/templates/terraform/examples/firestore_field_timestamp.tf.erb @@ -0,0 +1,9 @@ +resource "google_firestore_field" "<%= ctx[:primary_resource_id] %>" { + project = "<%= ctx[:test_env_vars]['project_id'] %>" + collection = "chatrooms_%{random_suffix}" + field = "timestamp" + + // Disable all single field indexes for the timestamp property. + index_config {} + ttl_config {} +} \ No newline at end of file diff --git a/mmv1/third_party/terraform/tests/resource_firestore_field_test.go b/mmv1/third_party/terraform/tests/resource_firestore_field_test.go new file mode 100644 index 000000000000..1a6fd0108eaa --- /dev/null +++ b/mmv1/third_party/terraform/tests/resource_firestore_field_test.go @@ -0,0 +1,135 @@ +package google + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccFirestoreField_firestoreFieldUpdateAddIndexExample(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "project_id": GetTestFirestoreProjectFromEnv(t), + "random_suffix": RandString(t, 10), + "resource_name": "add_index", + } + testAccFirestoreField_runUpdateTest(testAccFirestoreField_firestoreFieldUpdateAddIndexExample(context), t, context) +} + +func TestAccFirestoreField_firestoreFieldUpdateAddTTLExample(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "project_id": GetTestFirestoreProjectFromEnv(t), + "random_suffix": RandString(t, 10), + "resource_name": "add_ttl", + } + testAccFirestoreField_runUpdateTest(testAccFirestoreField_firestoreFieldUpdateAddTTLExample(context), t, context) +} + +func testAccFirestoreField_runUpdateTest(updateConfig string, t *testing.T, context map[string]interface{}) { + resourceName := context["resource_name"].(string) + + VcrTest(t, resource.TestCase{ + PreCheck: func() { AccTestPreCheck(t) }, + ProtoV5ProviderFactories: ProtoV5ProviderFactories(t), + CheckDestroy: testAccCheckFirestoreFieldDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccFirestoreField_firestoreFieldUpdateInitialExample(context), + }, + { + ResourceName: fmt.Sprintf("google_firestore_field.%s", resourceName), + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"database", "collection", "field"}, + }, + { + Config: updateConfig, + }, + { + ResourceName: fmt.Sprintf("google_firestore_field.%s", resourceName), + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"database", "collection", "field"}, + }, + { + Config: testAccFirestoreField_firestoreFieldUpdateInitialExample(context), + }, + { + ResourceName: fmt.Sprintf("google_firestore_field.%s", resourceName), + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"database", "collection", "field"}, + }, + }, + }) +} + +func testAccFirestoreField_firestoreFieldUpdateInitialExample(context map[string]interface{}) string { + return Nprintf(` +resource "google_firestore_field" "%{resource_name}" { + project = "%{project_id}" + collection = "chatrooms_%{random_suffix}" + field = "%{resource_name}" + + index_config { + indexes { + order = "ASCENDING" + query_scope = "COLLECTION_GROUP" + } + indexes { + array_config = "CONTAINS" + } + } +} +`, context) +} + +func testAccFirestoreField_firestoreFieldUpdateAddTTLExample(context map[string]interface{}) string { + return Nprintf(` +resource "google_firestore_field" "%{resource_name}" { + project = "%{project_id}" + collection = "chatrooms_%{random_suffix}" + field = "%{resource_name}" + + index_config { + indexes { + order = "ASCENDING" + query_scope = "COLLECTION_GROUP" + } + indexes { + array_config = "CONTAINS" + } + } + + ttl_config {} +} +`, context) +} + +func testAccFirestoreField_firestoreFieldUpdateAddIndexExample(context map[string]interface{}) string { + return Nprintf(` +resource "google_firestore_field" "%{resource_name}" { + project = "%{project_id}" + collection = "chatrooms_%{random_suffix}" + field = "%{resource_name}" + + index_config { + indexes { + order = "ASCENDING" + query_scope = "COLLECTION_GROUP" + } + indexes { + array_config = "CONTAINS" + } + indexes { + order = "DESCENDING" + query_scope = "COLLECTION_GROUP" + } + } +} +`, context) +}