Skip to content

Commit

Permalink
Introduce google_firestore_field resource. (#7853)
Browse files Browse the repository at this point in the history
* Introduce google_firestore_field resource.

Introduces a single resource for managing Firestore fields, matching the
REST API.

Introduces 2 primary divergences from the proto/REST API:

1) Firestore fields do not support deletion (this is because all fields
   implicitly can exist in a schemaless system). Deletion of a terraform
   resource will clear any "overrides" for this field (TTL or indexing).
2) Single field indexes in the proto API were overly nested to allow
   reusing the same resource for composite indexes. The terraform
   resource unnests the 1-length field in the indexes list.

fixes hashicorp/terraform-provider-google#12151
fixes hashicorp/terraform-provider-google#11419

* Address feedback in review (mostly minor cleanups).

* Fix whitespace and add database to basic example.
  • Loading branch information
pcostell authored May 5, 2023
1 parent 24b09ca commit a9c721c
Show file tree
Hide file tree
Showing 13 changed files with 590 additions and 0 deletions.
166 changes: 166 additions & 0 deletions mmv1/products/firestore/Field.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions mmv1/templates/terraform/custom_expand/empty_object_if_set.go.erb
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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}
}
29 changes: 29 additions & 0 deletions mmv1/templates/terraform/custom_import/firestore_field.go.erb
Original file line number Diff line number Diff line change
@@ -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<name>.+)"}, 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
Loading

0 comments on commit a9c721c

Please sign in to comment.