Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce google_firestore_field resource. #7853

Merged
merged 3 commits into from
May 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to ask about this default value, and whether there should be an acceptance tests that sets database to another value, but then I saw this in the docs:

A Cloud Firestore Database. Currently only one database is allowed per cloud project; this database must have a databaseId of '(default)'.

Noting this on the PR partly for myself as I'm unfamiliar with Firestore, partly for the benefit of anyone looking at the PR in future!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Once this restriction changes, I expect we should use generative ones for testing.

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
pcostell marked this conversation as resolved.
Show resolved Hide resolved
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