diff --git a/docs/resources/database.md b/docs/resources/database.md index 7be4da357b..de8d993e12 100644 --- a/docs/resources/database.md +++ b/docs/resources/database.md @@ -39,6 +39,20 @@ resource "snowflake_database" "test2" { - **from_database** (String) Specify a database to create a clone from. - **from_share** (Map of String) Specify a provider and a share in this map to create a database from a share. - **id** (String) The ID of this resource. +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) + + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. ## Import diff --git a/docs/resources/external_table.md b/docs/resources/external_table.md index df44186f31..02bc6da620 100644 --- a/docs/resources/external_table.md +++ b/docs/resources/external_table.md @@ -53,6 +53,7 @@ resource snowflake_external_table external_table { - **partition_by** (List of String) Specifies any partition columns to evaluate for the external table. - **pattern** (String) Specifies the file names and/or paths on the external stage to match. - **refresh_on_create** (Boolean) Specifies weather to refresh when an external table is created. +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) ### Read-Only @@ -67,6 +68,20 @@ Required: - **name** (String) Column name - **type** (String) Column type, e.g. VARIANT + + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. + ## Import Import is supported using the following syntax: diff --git a/docs/resources/materialized_view.md b/docs/resources/materialized_view.md index 9975349e14..100b5657c1 100644 --- a/docs/resources/materialized_view.md +++ b/docs/resources/materialized_view.md @@ -46,6 +46,20 @@ SQL - **id** (String) The ID of this resource. - **is_secure** (Boolean) Specifies that the view is secure. - **or_replace** (Boolean) Overwrites the View if it exists. +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) + + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. ## Import diff --git a/docs/resources/role.md b/docs/resources/role.md index 6dbbe8d0eb..cef699020a 100644 --- a/docs/resources/role.md +++ b/docs/resources/role.md @@ -30,6 +30,20 @@ resource snowflake_role role { - **comment** (String) - **id** (String) The ID of this resource. +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) + + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. ## Import diff --git a/docs/resources/schema.md b/docs/resources/schema.md index fec419b53c..639c6c65ba 100644 --- a/docs/resources/schema.md +++ b/docs/resources/schema.md @@ -39,6 +39,20 @@ resource snowflake_schema schema { - **id** (String) The ID of this resource. - **is_managed** (Boolean) Specifies a managed schema. Managed access schemas centralize privilege management with the schema owner. - **is_transient** (Boolean) Specifies a schema as transient. Transient schemas do not have a Fail-safe period so they do not incur additional storage costs once they leave Time Travel; however, this means they are also not protected by Fail-safe in the event of a data loss. +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) + + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. ## Import diff --git a/docs/resources/stage.md b/docs/resources/stage.md index 89a1665641..0ed3b82460 100644 --- a/docs/resources/stage.md +++ b/docs/resources/stage.md @@ -50,8 +50,22 @@ resource "snowflake_stage_grant" "grant_example_stage" { - **id** (String) The ID of this resource. - **snowflake_iam_user** (String) - **storage_integration** (String) Specifies the name of the storage integration used to delegate authentication responsibility for external cloud storage to a Snowflake identity and access management (IAM) entity. +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) - **url** (String) Specifies the URL for the stage. + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. + ## Import Import is supported using the following syntax: diff --git a/docs/resources/table.md b/docs/resources/table.md index 0971ced189..c72e5518a2 100644 --- a/docs/resources/table.md +++ b/docs/resources/table.md @@ -86,6 +86,7 @@ resource "snowflake_table" "table" { - **data_retention_days** (Number) Specifies the retention period for the table so that Time Travel actions (SELECT, CLONE, UNDROP) can be performed on historical data in the table. Default value is 1, if you wish to inherit the parent schema setting then pass in the schema attribute to this argument. - **id** (String) The ID of this resource. - **primary_key** (Block List, Max: 1) Definitions of primary key constraint to create on table (see [below for nested schema](#nestedblock--primary_key)) +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) ### Read-Only @@ -127,6 +128,20 @@ Optional: - **name** (String) Name of constraint + + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. + ## Import Import is supported using the following syntax: diff --git a/docs/resources/tag.md b/docs/resources/tag.md new file mode 100644 index 0000000000..3a14494ba1 --- /dev/null +++ b/docs/resources/tag.md @@ -0,0 +1,29 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "snowflake_tag Resource - terraform-provider-snowflake" +subcategory: "" +description: |- + +--- + +# snowflake_tag (Resource) + + + + + + +## Schema + +### Required + +- **database** (String) The database in which to create the tag. +- **name** (String) Specifies the identifier for the tag; must be unique for the database in which the tag is created. +- **schema** (String) The schema in which to create the tag. + +### Optional + +- **comment** (String) Specifies a comment for the tag. +- **id** (String) The ID of this resource. + + diff --git a/docs/resources/user.md b/docs/resources/user.md index 44c08ea947..d3008a4415 100644 --- a/docs/resources/user.md +++ b/docs/resources/user.md @@ -58,11 +58,25 @@ resource snowflake_user user { - **password** (String, Sensitive) **WARNING:** this will put the password in the terraform state file. Use carefully. - **rsa_public_key** (String) Specifies the user’s RSA public key; used for key-pair authentication. Must be on 1 line without header and trailer. - **rsa_public_key_2** (String) Specifies the user’s second RSA public key; used to rotate the public and private keys for key-pair authentication based on an expiration schedule set by your organization. Must be on 1 line without header and trailer. +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) ### Read-Only - **has_rsa_public_key** (Boolean) Will be true if user as an RSA key set. + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. + ## Import Import is supported using the following syntax: diff --git a/docs/resources/view.md b/docs/resources/view.md index 75e26623e2..9a693f3829 100644 --- a/docs/resources/view.md +++ b/docs/resources/view.md @@ -44,6 +44,20 @@ SQL - **id** (String) The ID of this resource. - **is_secure** (Boolean) Specifies that the view is secure. - **or_replace** (Boolean) Overwrites the View if it exists. +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) + + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. ## Import diff --git a/docs/resources/warehouse.md b/docs/resources/warehouse.md index d236c5e42e..4a390c0bf7 100644 --- a/docs/resources/warehouse.md +++ b/docs/resources/warehouse.md @@ -41,9 +41,23 @@ resource snowflake_warehouse w { - **scaling_policy** (String) Specifies the policy for automatically starting and shutting down clusters in a multi-cluster warehouse running in Auto-scale mode. - **statement_queued_timeout_in_seconds** (Number) Object parameter that specifies the time, in seconds, a SQL statement (query, DDL, DML, etc.) can be queued on a warehouse before it is canceled by the system. - **statement_timeout_in_seconds** (Number) Specifies the time, in seconds, after which a running SQL statement (query, DDL, DML, etc.) is canceled by the system +- **tag** (Block List) Definitions of a tag to associate with the resource. (see [below for nested schema](#nestedblock--tag)) - **wait_for_provisioning** (Boolean) Specifies whether the warehouse, after being resized, waits for all the servers to provision before executing any queued or new queries. - **warehouse_size** (String) Specifies the size of the virtual warehouse. Larger warehouse sizes 5X-Large and 6X-Large are currently in preview and only available on Amazon Web Services (AWS). + +### Nested Schema for `tag` + +Required: + +- **name** (String) Tag name, e.g. department. +- **value** (String) Tag value, e.g. marketing_info. + +Optional: + +- **database** (String) Name of the database that the tag was created in. +- **schema** (String) Name of the schema that the tag was created in. + ## Import Import is supported using the following syntax: diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 2d6582329f..95c589cd89 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -193,6 +193,7 @@ func getResources() map[string]*schema.Resource { "snowflake_stream": resources.Stream(), "snowflake_table": resources.Table(), "snowflake_external_table": resources.ExternalTable(), + "snowflake_tag": resources.Tag(), "snowflake_task": resources.Task(), "snowflake_user": resources.User(), "snowflake_user_public_keys": resources.UserPublicKeys(), diff --git a/pkg/resources/__debug_bin b/pkg/resources/__debug_bin deleted file mode 100755 index d7b3301986..0000000000 Binary files a/pkg/resources/__debug_bin and /dev/null differ diff --git a/pkg/resources/database.go b/pkg/resources/database.go index 0d67d0b67f..aec76ebf8a 100644 --- a/pkg/resources/database.go +++ b/pkg/resources/database.go @@ -41,6 +41,7 @@ var databaseSchema = map[string]*schema.Schema{ ForceNew: true, ConflictsWith: []string{"from_share"}, }, + "tag": tagReferenceSchema, } var databaseProperties = []string{"comment", "data_retention_time_in_days"} diff --git a/pkg/resources/external_table.go b/pkg/resources/external_table.go index a967fb1104..4a98a8827e 100644 --- a/pkg/resources/external_table.go +++ b/pkg/resources/external_table.go @@ -128,6 +128,7 @@ var externalTableSchema = map[string]*schema.Schema{ ForceNew: true, Description: "Name of the role that owns the external table.", }, + "tag": tagReferenceSchema, } func ExternalTable() *schema.Resource { @@ -233,6 +234,11 @@ func CreateExternalTable(data *schema.ResourceData, meta interface{}) error { builder.WithComment(v.(string)) } + if v, ok := data.GetOk("tag"); ok { + tags := getTags(v) + builder.WithTags(tags.toSnowflakeTagValues()) + } + stmt := builder.Create() err := snowflake.Exec(db, stmt) if err != nil { diff --git a/pkg/resources/helpers_test.go b/pkg/resources/helpers_test.go index 10eac28618..c07b317041 100644 --- a/pkg/resources/helpers_test.go +++ b/pkg/resources/helpers_test.go @@ -161,6 +161,14 @@ func stream(t *testing.T, id string, params map[string]interface{}) *schema.Reso return d } +func tag(t *testing.T, id string, params map[string]interface{}) *schema.ResourceData { + r := require.New(t) + d := schema.TestResourceDataRaw(t, resources.Tag().Schema, params) + r.NotNil(d) + d.SetId(id) + return d +} + func providers() map[string]*schema.Provider { p := provider.Provider() return map[string]*schema.Provider{ diff --git a/pkg/resources/materialized_view.go b/pkg/resources/materialized_view.go index 037646ca98..944e4c1735 100644 --- a/pkg/resources/materialized_view.go +++ b/pkg/resources/materialized_view.go @@ -61,6 +61,7 @@ var materializedViewSchema = map[string]*schema.Schema{ ForceNew: true, DiffSuppressFunc: DiffSuppressStatement, }, + "tag": tagReferenceSchema, } // View returns a pointer to the resource representing a view @@ -152,6 +153,11 @@ func CreateMaterializedView(d *schema.ResourceData, meta interface{}) error { builder.WithComment(v.(string)) } + if v, ok := d.GetOk("tag"); ok { + tags := getTags(v) + builder.WithTags(tags.toSnowflakeTagValues()) + } + q := builder.Create() log.Print("[DEBUG] xxx ", q) err := snowflake.ExecMulti(db, q) @@ -295,6 +301,8 @@ func UpdateMaterializedView(d *schema.ResourceData, meta interface{}) error { } } + handleTagChanges(db, d, builder) + return ReadMaterializedView(d, meta) } diff --git a/pkg/resources/resource.go b/pkg/resources/resource.go index b7d0a65721..23edbe95fb 100644 --- a/pkg/resources/resource.go +++ b/pkg/resources/resource.go @@ -34,6 +34,12 @@ func CreateResource( case schema.TypeInt: valInt := val.(int) qb.SetInt(field, valInt) + case schema.TypeList: + tags, ok := val.([]snowflake.TagValue) + if !ok { + continue + } + qb.SetTags(tags) } } } diff --git a/pkg/resources/role.go b/pkg/resources/role.go index c1781a7113..041613ee06 100644 --- a/pkg/resources/role.go +++ b/pkg/resources/role.go @@ -20,6 +20,7 @@ var roleSchema = map[string]*schema.Schema{ Optional: true, // TODO validation }, + "tag": tagReferenceSchema, } func Role() *schema.Resource { diff --git a/pkg/resources/schema.go b/pkg/resources/schema.go index 153b3c2cf5..3a0dea423b 100644 --- a/pkg/resources/schema.go +++ b/pkg/resources/schema.go @@ -58,6 +58,7 @@ var schemaSchema = map[string]*schema.Schema{ Description: "Specifies the number of days for which Time Travel actions (CLONE and UNDROP) can be performed on the schema, as well as specifying the default Time Travel retention time for all tables created in the schema.", ValidateFunc: validation.IntBetween(0, 90), }, + "tag": tagReferenceSchema, } type schemaID struct { @@ -144,6 +145,11 @@ func CreateSchema(d *schema.ResourceData, meta interface{}) error { builder.WithDataRetentionDays(v.(int)) } + if v, ok := d.GetOk("tag"); ok { + tags := getTags(v) + builder.WithTags(tags.toSnowflakeTagValues()) + } + q := builder.Create() err := snowflake.Exec(db, q) @@ -300,6 +306,8 @@ func UpdateSchema(d *schema.ResourceData, meta interface{}) error { } } + handleTagChanges(db, d, builder) + return ReadSchema(d, meta) } diff --git a/pkg/resources/stage.go b/pkg/resources/stage.go index ccec209842..bc7667b91e 100644 --- a/pkg/resources/stage.go +++ b/pkg/resources/stage.go @@ -82,6 +82,7 @@ var stageSchema = map[string]*schema.Schema{ Optional: true, Computed: true, }, + "tag": tagReferenceSchema, } type stageID struct { @@ -183,6 +184,11 @@ func CreateStage(d *schema.ResourceData, meta interface{}) error { builder.WithComment(v.(string)) } + if v, ok := d.GetOk("tag"); ok { + tags := getTags(v) + builder.WithTags(tags.toSnowflakeTagValues()) + } + q := builder.Create() err := snowflake.Exec(db, q) @@ -364,6 +370,8 @@ func UpdateStage(d *schema.ResourceData, meta interface{}) error { } } + handleTagChanges(db, d, builder) + return ReadStage(d, meta) } diff --git a/pkg/resources/table.go b/pkg/resources/table.go index abc63f72fd..2756854964 100644 --- a/pkg/resources/table.go +++ b/pkg/resources/table.go @@ -150,6 +150,7 @@ var tableSchema = map[string]*schema.Schema{ Default: false, Description: "Specifies whether to enable change tracking on the table. Default false.", }, + "tag": tagReferenceSchema, } func Table() *schema.Resource { @@ -281,23 +282,6 @@ func (c columns) toSnowflakeColumns() []snowflake.Column { return sC } -func (old columns) getNewIn(new columns) (added columns) { - added = columns{} - for _, cO := range old { - found := false - for _, cN := range new { - if cO.name == cN.name { - found = true - break - } - } - if !found { - added = append(added, cO) - } - } - return -} - type changedColumns []changedColumn type changedColumn struct { @@ -448,6 +432,11 @@ func CreateTable(d *schema.ResourceData, meta interface{}) error { builder.WithChangeTracking(v.(bool)) } + if v, ok := d.GetOk("tag"); ok { + tags := getTags(v) + builder.WithTags(tags.toSnowflakeTagValues()) + } + stmt := builder.Create() err := snowflake.Exec(db, stmt) if err != nil { @@ -680,6 +669,7 @@ func UpdateTable(d *schema.ResourceData, meta interface{}) error { return errors.Wrapf(err, "error changing property on %v", d.Id()) } } + handleTagChanges(db, d, builder) return ReadTable(d, meta) } diff --git a/pkg/resources/table_acceptance_test.go b/pkg/resources/table_acceptance_test.go index 769e83f8be..5a3c331aad 100644 --- a/pkg/resources/table_acceptance_test.go +++ b/pkg/resources/table_acceptance_test.go @@ -969,3 +969,86 @@ resource "snowflake_table" "test_table" { ` return fmt.Sprintf(s, name, name, name, name) } + +func TestAcc_TableTags(t *testing.T) { + accName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + tagName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + tag2Name := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + resource.ParallelTest(t, resource.TestCase{ + Providers: providers(), + Steps: []resource.TestStep{ + { + Config: tableWithTags(accName, tagName, tag2Name), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("snowflake_table.test_table", "name", accName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "database", accName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "schema", accName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "tag.0.name", tagName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "tag.0.value", accName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "tag.0.database", accName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "tag.0.schema", accName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "tag.1.name", tag2Name), + resource.TestCheckResourceAttr("snowflake_table.test_table", "tag.1.value", accName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "tag.1.database", accName), + resource.TestCheckResourceAttr("snowflake_table.test_table", "tag.1.schema", accName), + ), + }, + }, + }) +} + +func tableWithTags(name string, tagName string, tag2Name string) string { + s := ` +resource "snowflake_database" "test_database" { + name = "%[1]s" + comment = "Terraform acceptance test" +} + +resource "snowflake_schema" "test_schema" { + name = "%[1]s" + database = snowflake_database.test_database.name + comment = "Terraform acceptance test" +} + +resource "snowflake_tag" "test_tag" { + name = "%[2]s" + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + comment = "Terraform acceptance test" +} + +resource "snowflake_tag" "test2_tag" { + name = "%[3]s" + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + comment = "Terraform acceptance test" +} + +resource "snowflake_table" "test_table" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "%[1]s" + comment = "Terraform acceptance test" + + column { + name = "column1" + type = "VARCHAR(16)" + } + + tag { + name = snowflake_tag.test_tag.name + schema = snowflake_tag.test_tag.schema + database = snowflake_tag.test_tag.database + value = "%[1]s" + } + + tag { + name = snowflake_tag.test2_tag.name + schema = snowflake_tag.test2_tag.schema + database = snowflake_tag.test2_tag.database + value = "%[1]s" + } +} +` + return fmt.Sprintf(s, name, tagName, tag2Name) +} diff --git a/pkg/resources/tag.go b/pkg/resources/tag.go new file mode 100644 index 0000000000..cf28e65e46 --- /dev/null +++ b/pkg/resources/tag.go @@ -0,0 +1,437 @@ +package resources + +import ( + "bytes" + "database/sql" + "encoding/csv" + "fmt" + "log" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pkg/errors" + + "github.com/chanzuckerberg/terraform-provider-snowflake/pkg/snowflake" +) + +const ( + tagIDDelimiter = '|' +) + +var tagSchema = map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Specifies the identifier for the tag; must be unique for the database in which the tag is created.", + ForceNew: true, + }, + "database": { + Type: schema.TypeString, + Required: true, + Description: "The database in which to create the tag.", + ForceNew: true, + }, + "schema": { + Type: schema.TypeString, + Required: true, + Description: "The schema in which to create the tag.", + ForceNew: true, + }, + "comment": { + Type: schema.TypeString, + Optional: true, + Description: "Specifies a comment for the tag.", + }, +} + +var tagReferenceSchema = &schema.Schema{ + Type: schema.TypeList, + Required: false, + Optional: true, + ForceNew: true, + MinItems: 0, + Description: "Definitions of a tag to associate with the resource.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Tag name, e.g. department.", + }, + "value": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Tag value, e.g. marketing_info.", + }, + "database": { + Type: schema.TypeString, + Required: false, + Optional: true, + ForceNew: true, + Description: "Name of the database that the tag was created in.", + }, + "schema": { + Type: schema.TypeString, + Required: false, + Optional: true, + ForceNew: true, + Description: "Name of the schema that the tag was created in.", + }, + }, + }, +} + +type tagID struct { + DatabaseName string + SchemaName string + TagName string +} + +type TagBuilder interface { + UnsetTag(snowflake.TagValue) string + AddTag(snowflake.TagValue) string + ChangeTag(snowflake.TagValue) string +} + +func handleTagChanges(db *sql.DB, d *schema.ResourceData, builder TagBuilder) error { + if d.HasChange("tag") { + old, new := d.GetChange("tag") + removed, added, changed := getTags(old).diffs(getTags(new)) + for _, tA := range removed { + q := builder.UnsetTag(tA.toSnowflakeTagValue()) + err := snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error dropping tag on %v", d.Id()) + } + } + for _, tA := range added { + q := builder.AddTag(tA.toSnowflakeTagValue()) + + err := snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error adding column on %v", d.Id()) + } + } + for _, tA := range changed { + q := builder.ChangeTag(tA.toSnowflakeTagValue()) + err := snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error changing property on %v", d.Id()) + } + } + } + return nil +} + +// String() takes in a schemaID object and returns a pipe-delimited string: +// DatabaseName|SchemaName|TagName +func (ti *tagID) String() (string, error) { + var buf bytes.Buffer + csvWriter := csv.NewWriter(&buf) + csvWriter.Comma = schemaIDDelimiter + dataIdentifiers := [][]string{{ti.DatabaseName, ti.SchemaName, ti.TagName}} + err := csvWriter.WriteAll(dataIdentifiers) + if err != nil { + return "", err + } + strTagID := strings.TrimSpace(buf.String()) + return strTagID, nil +} + +// tagIDFromString() takes in a pipe-delimited string: DatabaseName|tagName +// and returns a tagID object +func tagIDFromString(stringID string) (*tagID, error) { + reader := csv.NewReader(strings.NewReader(stringID)) + reader.Comma = tagIDDelimiter + lines, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("not CSV compatible") + } + + if len(lines) != 1 { + return nil, fmt.Errorf("1 line per schema") + } + if len(lines[0]) != 3 { + return nil, fmt.Errorf("3 fields allowed") + } + + tagResult := &tagID{ + DatabaseName: lines[0][0], + SchemaName: lines[0][1], + TagName: lines[0][2], + } + return tagResult, nil +} + +// Schema returns a pointer to the resource representing a schema +func Tag() *schema.Resource { + return &schema.Resource{ + Create: CreateTag, + Read: ReadTag, + Update: UpdateTag, + Delete: DeleteTag, + + Schema: tagSchema, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + } +} + +// CreateSchema implements schema.CreateFunc +func CreateTag(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + name := d.Get("name").(string) + database := d.Get("database").(string) + schema := d.Get("schema").(string) + + builder := snowflake.Tag(name).WithDB(database).WithSchema(schema) + + // Set optionals + if v, ok := d.GetOk("comment"); ok { + builder.WithComment(v.(string)) + } + + q := builder.Create() + + err := snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error creating tag %v", name) + } + + tagID := &tagID{ + DatabaseName: database, + SchemaName: schema, + TagName: name, + } + dataIDInput, err := tagID.String() + if err != nil { + return err + } + d.SetId(dataIDInput) + + return ReadTag(d, meta) +} + +// ReadSchema implements schema.ReadFunc +func ReadTag(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + tagID, err := tagIDFromString(d.Id()) + if err != nil { + return err + } + + dbName := tagID.DatabaseName + schemaName := tagID.SchemaName + tag := tagID.TagName + + q := snowflake.Tag(tag).WithDB(dbName).WithSchema(schemaName).Show() + row := snowflake.QueryRow(db, q) + + t, err := snowflake.ScanTag(row) + if err == sql.ErrNoRows { + // If not found, mark resource to be removed from statefile during apply or refresh + log.Printf("[DEBUG] tag (%s) not found", d.Id()) + d.SetId("") + return nil + } + if err != nil { + return err + } + + err = d.Set("name", t.Name.String) + if err != nil { + return err + } + + err = d.Set("database", t.DatabaseName.String) + if err != nil { + return err + } + + err = d.Set("schema", t.SchemaName.String) + if err != nil { + return err + } + + err = d.Set("comment", t.Comment.String) + if err != nil { + return err + } + + return nil +} + +// UpdateTag implements schema.UpdateFunc +func UpdateTag(d *schema.ResourceData, meta interface{}) error { + tagID, err := tagIDFromString(d.Id()) + if err != nil { + return err + } + + dbName := tagID.DatabaseName + schemaName := tagID.SchemaName + tag := tagID.TagName + + builder := snowflake.Tag(tag).WithDB(dbName).WithSchema(schemaName) + + db := meta.(*sql.DB) + if d.HasChange("comment") { + comment, ok := d.GetOk("comment") + var q string + if ok { + q = builder.ChangeComment(comment.(string)) + } else { + q = builder.RemoveComment() + } + err := snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error updating tag comment on %v", d.Id()) + } + } + + return ReadTag(d, meta) +} + +// DeleteTag implements schema.DeleteFunc +func DeleteTag(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + tagID, err := tagIDFromString(d.Id()) + if err != nil { + return err + } + + dbName := tagID.DatabaseName + schemaName := tagID.SchemaName + tag := tagID.TagName + + q := snowflake.Tag(tag).WithDB(dbName).WithSchema(schemaName).Drop() + + err = snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error deleting tag %v", d.Id()) + } + + d.SetId("") + + return nil +} + +// SchemaExists implements schema.ExistsFunc +func TagExists(data *schema.ResourceData, meta interface{}) (bool, error) { + db := meta.(*sql.DB) + tagID, err := tagIDFromString(data.Id()) + if err != nil { + return false, err + } + + dbName := tagID.DatabaseName + schemaName := tagID.SchemaName + tag := tagID.TagName + + q := snowflake.Tag(tag).WithDB(dbName).WithSchema(schemaName).Show() + rows, err := db.Query(q) + if err != nil { + return false, err + } + defer rows.Close() + + if rows.Next() { + return true, nil + } + + return false, nil +} + +type tags []tag + +func (t tags) toSnowflakeTagValues() []snowflake.TagValue { + sT := make([]snowflake.TagValue, len(t)) + for i, tag := range t { + sT[i] = tag.toSnowflakeTagValue() + } + return sT +} + +func (tag tag) toSnowflakeTagValue() snowflake.TagValue { + return snowflake.TagValue{ + Name: tag.name, + Value: tag.value, + Database: tag.database, + Schema: tag.schema, + } +} + +func (old tags) getNewIn(new tags) (added tags) { + added = tags{} + for _, t0 := range old { + found := false + for _, cN := range new { + if t0.name == cN.name { + found = true + break + } + } + if !found { + added = append(added, t0) + } + } + return +} + +func (old tags) getChangedTagProperties(new tags) (changed tags) { + changed = tags{} + for _, t0 := range old { + for _, tN := range new { + if t0.name == tN.name && t0.value != tN.value { + changed = append(changed, tN) + } + } + } + return +} + +func (old tags) diffs(new tags) (removed tags, added tags, changed tags) { + return old.getNewIn(new), new.getNewIn(old), old.getChangedTagProperties(new) +} + +func (old columns) getNewIn(new columns) (added columns) { + added = columns{} + for _, cO := range old { + found := false + for _, cN := range new { + if cO.name == cN.name { + found = true + break + } + } + if !found { + added = append(added, cO) + } + } + return +} + +type tag struct { + name string + value string + database string + schema string +} + +func getTags(from interface{}) (to tags) { + tags := from.([]interface{}) + to = make([]tag, len(tags)) + for i, t := range tags { + v := t.(map[string]interface{}) + to[i] = tag{ + name: v["name"].(string), + value: v["value"].(string), + database: v["database"].(string), + schema: v["schema"].(string), + } + } + return to +} diff --git a/pkg/resources/tag_acceptance_test.go b/pkg/resources/tag_acceptance_test.go new file mode 100644 index 0000000000..813d56cb0e --- /dev/null +++ b/pkg/resources/tag_acceptance_test.go @@ -0,0 +1,63 @@ +package resources_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAcc_Tag(t *testing.T) { + accName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + + resource.ParallelTest(t, resource.TestCase{ + Providers: providers(), + Steps: []resource.TestStep{ + { + Config: tagConfig(accName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("snowflake_tag.test", "name", accName), + resource.TestCheckResourceAttr("snowflake_tag.test", "database", accName), + resource.TestCheckResourceAttr("snowflake_tag.test", "schema", accName), + resource.TestCheckResourceAttr("snowflake_tag.test", "comment", "Terraform acceptance test"), + ), + }, + }, + }) +} + +func tagConfig(n string) string { + return fmt.Sprintf(` +resource "snowflake_database" "test" { + name = "%[1]v" + comment = "Terraform acceptance test" +} + +resource "snowflake_schema" "test" { + name = "%[1]v" + database = snowflake_database.test.name + comment = "Terraform acceptance test" +} + +resource "snowflake_tag" "test" { + name = "%[1]v" + database = snowflake_database.test.name + schema = snowflake_schema.test.name + comment = "Terraform acceptance test" +} + +resource "snowflake_database" "test2" { + name = "%[1]v_2" + comment = "Terraform acceptance test2 with tags" + + tag { + name = snowflake_tag.test.name + schema = snowflake_tag.test.schema + database = snowflake_tag.test.database + value = "%[1]v" + } +} +`, n) +} diff --git a/pkg/resources/tag_test.go b/pkg/resources/tag_test.go new file mode 100644 index 0000000000..29561b5ff1 --- /dev/null +++ b/pkg/resources/tag_test.go @@ -0,0 +1,120 @@ +package resources_test + +import ( + "database/sql" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/chanzuckerberg/terraform-provider-snowflake/pkg/provider" + "github.com/chanzuckerberg/terraform-provider-snowflake/pkg/resources" + "github.com/chanzuckerberg/terraform-provider-snowflake/pkg/snowflake" + . "github.com/chanzuckerberg/terraform-provider-snowflake/pkg/testhelpers" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/require" +) + +func TestTag(t *testing.T) { + r := require.New(t) + err := resources.Tag().InternalValidate(provider.Provider().Schema, true) + r.NoError(err) +} + +func TestTagCreate(t *testing.T) { + r := require.New(t) + + in := map[string]interface{}{ + "name": "good_name", + "database": "test_db", + "schema": "test_schema", + "comment": "great comment", + } + d := schema.TestResourceDataRaw(t, resources.Tag().Schema, in) + r.NotNil(d) + + WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) { + mock.ExpectExec( + `^CREATE TAG "test_db"."test_schema"."good_name" COMMENT = 'great comment'$`, + ).WillReturnResult(sqlmock.NewResult(1, 1)) + + expectReadTag(mock) + err := resources.CreateTag(d, db) + r.NoError(err) + }) +} + +func TestTagUpdate(t *testing.T) { + r := require.New(t) + + in := map[string]interface{}{ + "name": "good_name", + "database": "test_db", + "schema": "test_schema", + "comment": "great comment", + } + + d := tag(t, "test_db|test_schema|good_name", in) + r.NotNil(d) + + WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) { + mock.ExpectExec( + `^ALTER TAG "test_db"."test_schema"."good_name" SET COMMENT = 'great comment'$`, + ).WillReturnResult(sqlmock.NewResult(1, 1)) + + expectReadTag(mock) + err := resources.UpdateTag(d, db) + r.NoError(err) + }) +} + +func TestTagDelete(t *testing.T) { + r := require.New(t) + + in := map[string]interface{}{ + "name": "good_name", + "database": "test_db", + "schema": "test_schema", + "comment": "great comment", + } + + d := tag(t, "test_db|test_schema|good_name", in) + r.NotNil(d) + + WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) { + mock.ExpectExec( + `^DROP TAG "test_db"."test_schema"."good_name"$`, + ).WillReturnResult(sqlmock.NewResult(1, 1)) + + err := resources.DeleteTag(d, db) + r.NoError(err) + }) +} + +func TestTagRead(t *testing.T) { + r := require.New(t) + + in := map[string]interface{}{ + "name": "good_name", + "database": "test_db", + "schema": "test_schema", + } + + d := schema.TestResourceDataRaw(t, resources.Tag().Schema, in) + d.SetId("test_db|test_schema|good_name") + + WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) { + // Test when resource is not found, checking if state will be empty + r.NotEmpty(d.State()) + q := snowflake.Tag("good_name").WithDB("test_db").WithSchema("test_schema").Show() + mock.ExpectQuery(q).WillReturnError(sql.ErrNoRows) + err := resources.ReadTag(d, db) + r.Empty(d.State()) + r.Nil(err) + }) +} + +func expectReadTag(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{ + "created_on", "name", "database_name", "schema_name", "owner", "comment"}, + ).AddRow("2019-05-19 16:55:36.530 -0700", "good_name", "test_db", "test_schema", "admin", "great comment") + mock.ExpectQuery(`^SHOW TAGS LIKE 'good_name' IN SCHEMA "test_db"."test_schema"$`).WillReturnRows(rows) +} diff --git a/pkg/resources/user.go b/pkg/resources/user.go index 20c35740ff..420614ee97 100644 --- a/pkg/resources/user.go +++ b/pkg/resources/user.go @@ -118,6 +118,7 @@ var userSchema = map[string]*schema.Schema{ Optional: true, Description: "Last name of the user.", }, + "tag": tagReferenceSchema, // MIDDLE_NAME = // SNOWFLAKE_LOCK = TRUE | FALSE diff --git a/pkg/resources/view.go b/pkg/resources/view.go index e9b08fe481..12848703eb 100644 --- a/pkg/resources/view.go +++ b/pkg/resources/view.go @@ -56,6 +56,7 @@ var viewSchema = map[string]*schema.Schema{ ForceNew: true, DiffSuppressFunc: DiffSuppressStatement, }, + "tag": tagReferenceSchema, } func normalizeQuery(str string) string { @@ -114,6 +115,11 @@ func CreateView(d *schema.ResourceData, meta interface{}) error { builder.WithComment(v.(string)) } + if v, ok := d.GetOk("tag"); ok { + tags := getTags(v) + builder.WithTags(tags.toSnowflakeTagValues()) + } + q, err := builder.Create() if err != nil { return err @@ -256,6 +262,33 @@ func UpdateView(d *schema.ResourceData, meta interface{}) error { } } } + handleTagChanges(db, d, builder) + if d.HasChange("tag") { + old, new := d.GetChange("tag") + removed, added, changed := getTags(old).diffs(getTags(new)) + for _, tA := range removed { + q := builder.UnsetTag(tA.toSnowflakeTagValue()) + err := snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error dropping tag on %v", d.Id()) + } + } + for _, tA := range added { + q := builder.AddTag(tA.toSnowflakeTagValue()) + + err := snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error adding column on %v", d.Id()) + } + } + for _, tA := range changed { + q := builder.ChangeTag(tA.toSnowflakeTagValue()) + err := snowflake.Exec(db, q) + if err != nil { + return errors.Wrapf(err, "error changing property on %v", d.Id()) + } + } + } return ReadView(d, meta) } diff --git a/pkg/resources/warehouse.go b/pkg/resources/warehouse.go index 52d24b0cc1..d1939d0189 100644 --- a/pkg/resources/warehouse.go +++ b/pkg/resources/warehouse.go @@ -120,6 +120,7 @@ var warehouseSchema = map[string]*schema.Schema{ Default: 0, Description: "Object parameter that specifies the concurrency level for SQL statements (i.e. queries and DML) executed by a warehouse.", }, + "tag": tagReferenceSchema, } // Warehouse returns a pointer to the resource representing a warehouse diff --git a/pkg/snowflake/external_table.go b/pkg/snowflake/external_table.go index 179206e7bd..64209434d0 100644 --- a/pkg/snowflake/external_table.go +++ b/pkg/snowflake/external_table.go @@ -25,6 +25,7 @@ type ExternalTableBuilder struct { copyGrants bool awsSNSTopic string comment string + tags []TagValue } // QualifiedName prepends the db and schema if set and escapes everything nicely @@ -92,6 +93,12 @@ func (tb *ExternalTableBuilder) WithAwsSNSTopic(c string) *ExternalTableBuilder return tb } +// WithTags sets the tags on the ExternalTableBuilder +func (tb *ExternalTableBuilder) WithTags(tags []TagValue) *ExternalTableBuilder { + tb.tags = tags + return tb +} + // ExternalexternalTable returns a pointer to a Builder that abstracts the DDL operations for a externalTable. // // Supported DDL operations are: diff --git a/pkg/snowflake/generic.go b/pkg/snowflake/generic.go index a458319b2c..86772e5b43 100644 --- a/pkg/snowflake/generic.go +++ b/pkg/snowflake/generic.go @@ -63,6 +63,7 @@ type AlterPropertiesBuilder struct { intProperties map[string]int floatProperties map[string]float64 rawStatement string + tags []TagValue } func (b *Builder) Alter() *AlterPropertiesBuilder { @@ -103,6 +104,10 @@ func (ab *AlterPropertiesBuilder) SetRaw(rawStatement string) { ab.rawStatement = sb.String() } +func (b *AlterPropertiesBuilder) SetTags(tags []TagValue) { + b.tags = tags +} + func (ab *AlterPropertiesBuilder) Statement() string { var sb strings.Builder sb.WriteString(fmt.Sprintf(`ALTER %s "%s" SET`, ab.entityType, ab.name)) // TODO handle error @@ -129,6 +134,10 @@ func (ab *AlterPropertiesBuilder) Statement() string { sb.WriteString(fmt.Sprintf(" %s=%.2f", strings.ToUpper(k), ab.floatProperties[k])) } + for _, t := range sortTags(ab.tags) { + sb.WriteString(fmt.Sprintf(` TAG "%v"."%v"."%v" = "%v"`, t.Database, t.Schema, t.Name, t.Value)) + } + return sb.String() } @@ -141,6 +150,7 @@ type CreateBuilder struct { intProperties map[string]int floatProperties map[string]float64 rawStatement string + tags []TagValue } func (b *Builder) Create() *CreateBuilder { @@ -181,6 +191,10 @@ func (b *CreateBuilder) SetRaw(rawStatement string) { b.rawStatement = sb.String() } +func (b *CreateBuilder) SetTags(tags []TagValue) { + b.tags = tags +} + func (b *CreateBuilder) Statement() string { var sb strings.Builder sb.WriteString(fmt.Sprintf(`CREATE %s "%s"`, b.entityType, b.name)) // TODO handle error @@ -207,6 +221,15 @@ func (b *CreateBuilder) Statement() string { sb.WriteString(fmt.Sprintf(" %s=%.2f", strings.ToUpper(k), b.floatProperties[k])) } + for i, t := range sortTags(b.tags) { + if i == 0 { + sb.WriteString(` TAG`) + } else if i < len(b.tags)-1 { + sb.WriteString(`, `) + } + sb.WriteString(fmt.Sprintf(`"%v"."%v"."%v" = "%v"`, t.Database, t.Schema, t.Name, t.Value)) + } + return sb.String() } diff --git a/pkg/snowflake/materialized_view.go b/pkg/snowflake/materialized_view.go index ecacdc33f0..f44da75113 100644 --- a/pkg/snowflake/materialized_view.go +++ b/pkg/snowflake/materialized_view.go @@ -20,6 +20,7 @@ type MaterializedViewBuilder struct { replace bool comment string statement string + tags []TagValue } // QualifiedName prepends the db and schema if set and escapes everything nicely @@ -86,6 +87,27 @@ func (vb *MaterializedViewBuilder) WithStatement(s string) *MaterializedViewBuil return vb } +// WithTags sets the tags on the ExternalTableBuilder +func (vb *MaterializedViewBuilder) WithTags(tags []TagValue) *MaterializedViewBuilder { + vb.tags = tags + return vb +} + +// AddTag returns the SQL query that will add a new tag to the view. +func (vb *MaterializedViewBuilder) AddTag(tag TagValue) string { + return fmt.Sprintf(`ALTER MATERIALIZED VIEW %s SET TAG "%v"."%v"."%v" = "%v"`, vb.QualifiedName(), tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// ChangeTag returns the SQL query that will alter a tag on the view. +func (vb *MaterializedViewBuilder) ChangeTag(tag TagValue) string { + return fmt.Sprintf(`ALTER MATERIALIZED VIEW %s SET TAG "%v"."%v"."%v" = "%v"`, vb.QualifiedName(), tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// UnsetTag returns the SQL query that will unset a tag on the view. +func (vb *MaterializedViewBuilder) UnsetTag(tag TagValue) string { + return fmt.Sprintf(`ALTER MATERIALIZED VIEW %s UNSET TAG "%v"."%v"."%v"`, vb.QualifiedName(), tag.Database, tag.Schema, tag.Name) +} + // View returns a pointer to a Builder that abstracts the DDL operations for a view. // // Supported DDL operations are: diff --git a/pkg/snowflake/schema.go b/pkg/snowflake/schema.go index 6ff94ee6fe..a4e47dc982 100644 --- a/pkg/snowflake/schema.go +++ b/pkg/snowflake/schema.go @@ -19,6 +19,7 @@ type SchemaBuilder struct { transient bool setDataRetentionDays bool dataRetentionDays int + tags []TagValue } // QualifiedName prepends the db if set and escapes everything nicely @@ -66,6 +67,27 @@ func (sb *SchemaBuilder) WithDB(db string) *SchemaBuilder { return sb } +// WithTags sets the tags on the SchemaBuilder +func (sb *SchemaBuilder) WithTags(tags []TagValue) *SchemaBuilder { + sb.tags = tags + return sb +} + +// AddTag returns the SQL query that will add a new tag to the schema. +func (sb *SchemaBuilder) AddTag(tag TagValue) string { + return fmt.Sprintf(`ALTER SCHEMA %s SET TAG "%v"."%v"."%v" = "%v"`, sb.QualifiedName(), tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// ChangeTag returns the SQL query that will alter a tag on the schema. +func (sb *SchemaBuilder) ChangeTag(tag TagValue) string { + return fmt.Sprintf(`ALTER SCHEMA %s SET TAG "%v"."%v"."%v" = "%v"`, sb.QualifiedName(), tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// UnsetTag returns the SQL query that will unset a tag on the schema. +func (sb *SchemaBuilder) UnsetTag(tag TagValue) string { + return fmt.Sprintf(`ALTER SCHEMA %s UNSET TAG "%v"."%v"."%v"`, sb.QualifiedName(), tag.Database, tag.Schema, tag.Name) +} + // Schema returns a pointer to a Builder that abstracts the DDL operations for a schema. // // Supported DDL operations are: diff --git a/pkg/snowflake/sorting.go b/pkg/snowflake/sorting.go index b49f62231a..31d148f8dc 100644 --- a/pkg/snowflake/sorting.go +++ b/pkg/snowflake/sorting.go @@ -1,6 +1,7 @@ package snowflake import ( + "fmt" "sort" ) @@ -57,3 +58,12 @@ func sortStringsBool(strs map[string]bool) []string { sort.Strings(sortedStringProperties) return sortedStringProperties } + +func sortTags(tags []TagValue) []TagValue { + sort.Slice(tags, func(i, j int) bool { + qn1 := fmt.Sprintf("%s.%s.%s", tags[i].Database, tags[i].Schema, tags[i].Name) + qn2 := fmt.Sprintf("%s.%s.%s", tags[j].Database, tags[j].Schema, tags[j].Name) + return qn1 < qn2 + }) + return tags +} diff --git a/pkg/snowflake/stage.go b/pkg/snowflake/stage.go index 0a11597f3b..1b12d75ec3 100644 --- a/pkg/snowflake/stage.go +++ b/pkg/snowflake/stage.go @@ -26,6 +26,7 @@ type StageBuilder struct { fileFormat string copyOptions string comment string + tags []TagValue } // QualifiedName prepends the db and schema and escapes everything nicely @@ -79,6 +80,27 @@ func (sb *StageBuilder) WithComment(c string) *StageBuilder { return sb } +// WithTags sets the tags on the ExternalTableBuilder +func (sb *StageBuilder) WithTags(tags []TagValue) *StageBuilder { + sb.tags = tags + return sb +} + +// AddTag returns the SQL query that will add a new tag to the view. +func (sb *StageBuilder) AddTag(tag TagValue) string { + return fmt.Sprintf(`ALTER STAGE %s SET TAG "%v"."%v"."%v" = "%v"`, sb.QualifiedName(), tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// ChangeTag returns the SQL query that will alter a tag on the view. +func (sb *StageBuilder) ChangeTag(tag TagValue) string { + return fmt.Sprintf(`ALTER STAGE %s SET TAG "%v"."%v"."%v" = "%v"`, sb.QualifiedName(), tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// UnsetTag returns the SQL query that will unset a tag on the view. +func (sb *StageBuilder) UnsetTag(tag TagValue) string { + return fmt.Sprintf(`ALTER STAGE %s UNSET TAG "%v"."%v"."%v"`, sb.QualifiedName(), tag.Database, tag.Schema, tag.Name) +} + // Stage returns a pointer to a Builder that abstracts the DDL operations for a stage. // // Supported DDL operations are: diff --git a/pkg/snowflake/table.go b/pkg/snowflake/table.go index 58802f33fe..d7b4ca405c 100644 --- a/pkg/snowflake/table.go +++ b/pkg/snowflake/table.go @@ -268,6 +268,7 @@ type TableBuilder struct { dataRetentionTimeInDays int changeTracking bool defaultDDLCollation string + tags []TagValue } // QualifiedName prepends the db and schema if set and escapes everything nicely @@ -327,12 +328,48 @@ func (tb *TableBuilder) WithChangeTracking(changeTracking bool) *TableBuilder { return tb } +// WithTags sets the tags on the TableBuilder +func (tb *TableBuilder) WithTags(tags []TagValue) *TableBuilder { + tb.tags = tags + return tb +} + +// AddTag returns the SQL query that will add a new tag to the table. +func (tb *TableBuilder) AddTag(tag TagValue) string { + return fmt.Sprintf(`ALTER TABLE %s SET TAG "%v"."%v"."%v" = "%v"`, tb.QualifiedName(), tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// ChangeTag returns the SQL query that will alter a tag on the table. +func (tb *TableBuilder) ChangeTag(tag TagValue) string { + return fmt.Sprintf(`ALTER TABLE %s SET TAG "%v"."%v"."%v" = "%v"`, tb.QualifiedName(), tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// UnsetTag returns the SQL query that will unset a tag on the table. +func (tb *TableBuilder) UnsetTag(tag TagValue) string { + return fmt.Sprintf(`ALTER TABLE %s UNSET TAG "%v"."%v"."%v"`, tb.QualifiedName(), tag.Database, tag.Schema, tag.Name) +} + //Function to get clustering definition func (tb *TableBuilder) GetClusterKeyString() string { return JoinStringList(tb.clusterBy[:], ", ") } +func (tb *TableBuilder) GetTagValueString() string { + var q strings.Builder + for _, v := range tb.tags { + fmt.Println(v) + if v.Schema != "" { + if v.Database != "" { + q.WriteString(fmt.Sprintf(`"%v".`, v.Database)) + } + q.WriteString(fmt.Sprintf(`"%v".`, v.Schema)) + } + q.WriteString(fmt.Sprintf(`"%v" = "%v", `, v.Name, v.Value)) + } + return strings.TrimSuffix(q.String(), ", ") +} + func JoinStringList(instrings []string, delimiter string) string { return fmt.Sprint(strings.Join(instrings[:], delimiter)) @@ -442,6 +479,10 @@ func (tb *TableBuilder) Create() string { q.WriteString(fmt.Sprintf(` DATA_RETENTION_TIME_IN_DAYS = %d`, tb.dataRetentionTimeInDays)) q.WriteString(fmt.Sprintf(` CHANGE_TRACKING = %t`, tb.changeTracking)) + if tb.tags != nil { + q.WriteString(fmt.Sprintf(` WITH TAG (%v)`, tb.GetTagValueString())) + } + return q.String() } diff --git a/pkg/snowflake/table_test.go b/pkg/snowflake/table_test.go index cd687ba6fd..460b6e7623 100644 --- a/pkg/snowflake/table_test.go +++ b/pkg/snowflake/table_test.go @@ -42,6 +42,22 @@ func TestTableCreate(t *testing.T) { } s.WithColumns(Columns(cols)) + + tags := []TagValue{ + { + Name: "tag", + Database: "test_db", + Schema: "test_schema", + Value: "value", + }, + { + Name: "tag2", + Database: "test_db", + Schema: "test_schema", + Value: "value2", + }, + } + r.Equal(s.QualifiedName(), `"test_db"."test_schema"."test_table"`) r.Equal(`CREATE TABLE "test_db"."test_schema"."test_table" ("column1" OBJECT COMMENT '', "column2" VARCHAR COMMENT 'only populated when data is available', "column3" NUMBER(38,0) NOT NULL DEFAULT "test_db"."test_schema"."test_seq".NEXTVAL COMMENT '', "column4" VARCHAR NOT NULL DEFAULT 'test default''s' COMMENT '', "column5" TIMESTAMP_NTZ NOT NULL DEFAULT CURRENT_TIMESTAMP() COMMENT '') DATA_RETENTION_TIME_IN_DAYS = 0 CHANGE_TRACKING = false`, s.Create()) @@ -60,6 +76,9 @@ func TestTableCreate(t *testing.T) { s.WithChangeTracking(true) r.Equal(s.Create(), `CREATE TABLE "test_db"."test_schema"."test_table" ("column1" OBJECT COMMENT '', "column2" VARCHAR COMMENT 'only populated when data is available', "column3" NUMBER(38,0) NOT NULL DEFAULT "test_db"."test_schema"."test_seq".NEXTVAL COMMENT '', "column4" VARCHAR NOT NULL DEFAULT 'test default''s' COMMENT '', "column5" TIMESTAMP_NTZ NOT NULL DEFAULT CURRENT_TIMESTAMP() COMMENT '' ,CONSTRAINT "MY_KEY" PRIMARY KEY("column1")) COMMENT = 'Test Comment' CLUSTER BY LINEAR(column1) DATA_RETENTION_TIME_IN_DAYS = 10 CHANGE_TRACKING = true`) + + s.WithTags(tags) + r.Equal(s.Create(), `CREATE TABLE "test_db"."test_schema"."test_table" ("column1" OBJECT COMMENT '', "column2" VARCHAR COMMENT 'only populated when data is available', "column3" NUMBER(38,0) NOT NULL DEFAULT "test_db"."test_schema"."test_seq".NEXTVAL COMMENT '', "column4" VARCHAR NOT NULL DEFAULT 'test default''s' COMMENT '', "column5" TIMESTAMP_NTZ NOT NULL DEFAULT CURRENT_TIMESTAMP() COMMENT '' ,CONSTRAINT "MY_KEY" PRIMARY KEY("column1")) COMMENT = 'Test Comment' CLUSTER BY LINEAR(column1) DATA_RETENTION_TIME_IN_DAYS = 10 CHANGE_TRACKING = true WITH TAG ("test_db"."test_schema"."tag" = "value", "test_db"."test_schema"."tag2" = "value2")`) } func TestTableChangeComment(t *testing.T) { @@ -175,3 +194,21 @@ func TestTableChangePrimaryKeysWithoutConstraintName(t *testing.T) { s := Table("test_table", "test_db", "test_schema") r.Equal(s.ChangePrimaryKey(PrimaryKey{name: "", keys: []string{"column1", "column2"}}), `ALTER TABLE "test_db"."test_schema"."test_table" ADD PRIMARY KEY("column1", "column2")`) } + +func TestTableAddTag(t *testing.T) { + r := require.New(t) + s := Table("test_table", "test_db", "test_schema") + r.Equal(s.AddTag(TagValue{Name: "tag", Schema: "test_schema", Database: "test_db", Value: "value"}), `ALTER TABLE "test_db"."test_schema"."test_table" SET TAG "test_db"."test_schema"."tag" = "value"`) +} + +func TestTableChangeTag(t *testing.T) { + r := require.New(t) + s := Table("test_table", "test_db", "test_schema") + r.Equal(s.ChangeTag(TagValue{Name: "tag", Schema: "test_schema", Database: "test_db", Value: "value"}), `ALTER TABLE "test_db"."test_schema"."test_table" SET TAG "test_db"."test_schema"."tag" = "value"`) +} + +func TestTableUnsetTag(t *testing.T) { + r := require.New(t) + s := Table("test_table", "test_db", "test_schema") + r.Equal(s.UnsetTag(TagValue{Name: "tag", Schema: "test_schema", Database: "test_db"}), `ALTER TABLE "test_db"."test_schema"."test_table" UNSET TAG "test_db"."test_schema"."tag"`) +} diff --git a/pkg/snowflake/tag.go b/pkg/snowflake/tag.go new file mode 100644 index 0000000000..8d17d683b5 --- /dev/null +++ b/pkg/snowflake/tag.go @@ -0,0 +1,162 @@ +package snowflake + +import ( + "database/sql" + "fmt" + "log" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" +) + +// TagBuilder abstracts the creation of SQL queries for a Snowflake tag +type TagBuilder struct { + name string + db string + schema string + comment string +} + +// QualifiedName prepends the db and schema if set and escapes everything nicely +func (tb *TagBuilder) QualifiedName() string { + var n strings.Builder + + if tb.db != "" { + n.WriteString(fmt.Sprintf(`"%v".`, tb.db)) + } + + if tb.schema != "" { + n.WriteString(fmt.Sprintf(`"%v".`, tb.schema)) + } + + n.WriteString(fmt.Sprintf(`"%v"`, tb.name)) + + return n.String() +} + +// WithComment adds a comment to the TagBuilder +func (tb *TagBuilder) WithComment(c string) *TagBuilder { + tb.comment = c + return tb +} + +// WithDB adds the name of the database to the TagBuilder +func (tb *TagBuilder) WithDB(db string) *TagBuilder { + tb.db = db + return tb +} + +// WithSchema adds the name of the schema to the TagBuilder +func (tb *TagBuilder) WithSchema(schema string) *TagBuilder { + tb.schema = schema + return tb +} + +// Tag returns a pointer to a Builder that abstracts the DDL operations for a tag. +// +// Supported DDL operations are: +// - CREATE TAG +// - ALTER TAG +// - DROP TAG +// - UNDROP TAG +// - SHOW TAGS +// +// [Snowflake Reference](https://docs.snowflake.com/en/user-guide/object-tagging.html) +func Tag(name string) *TagBuilder { + return &TagBuilder{ + name: name, + } +} + +// Create returns the SQL query that will create a new tag. +func (tb *TagBuilder) Create() string { + q := strings.Builder{} + q.WriteString(`CREATE`) + + q.WriteString(fmt.Sprintf(` TAG %v`, tb.QualifiedName())) + + if tb.comment != "" { + q.WriteString(fmt.Sprintf(` COMMENT = '%v'`, EscapeString(tb.comment))) + } + + return q.String() +} + +// Rename returns the SQL query that will rename the tag. +func (tb *TagBuilder) Rename(newName string) string { + return fmt.Sprintf(`ALTER TAG %v RENAME TO "%v"`, tb.QualifiedName(), newName) +} + +// ChangeComment returns the SQL query that will update the comment on the tag. +func (tb *TagBuilder) ChangeComment(c string) string { + return fmt.Sprintf(`ALTER TAG %v SET COMMENT = '%v'`, tb.QualifiedName(), EscapeString(c)) +} + +// RemoveComment returns the SQL query that will remove the comment on the tag. +func (tb *TagBuilder) RemoveComment() string { + return fmt.Sprintf(`ALTER TAG %v UNSET COMMENT`, tb.QualifiedName()) +} + +// Drop returns the SQL query that will drop a tag. +func (tb *TagBuilder) Drop() string { + return fmt.Sprintf(`DROP TAG %v`, tb.QualifiedName()) +} + +// Undrop returns the SQL query that will undrop a tag. +func (tb *TagBuilder) Undrop() string { + return fmt.Sprintf(`UNDROP TAG %v`, tb.QualifiedName()) +} + +// Show returns the SQL query that will show a tag. +func (tb *TagBuilder) Show() string { + q := strings.Builder{} + + q.WriteString(fmt.Sprintf(`SHOW TAGS LIKE '%v'`, tb.name)) + + if tb.schema != "" && tb.db != "" { + q.WriteString(fmt.Sprintf(` IN SCHEMA "%v"."%v"`, tb.db, tb.schema)) + } else if tb.db != "" { + q.WriteString(fmt.Sprintf(` IN DATABASE "%v"`, tb.db)) + } + + return q.String() +} + +type tag struct { + Name sql.NullString `db:"name"` + DatabaseName sql.NullString `db:"database_name"` + SchemaName sql.NullString `db:"schema_name"` + Comment sql.NullString `db:"comment"` +} + +type TagValue struct { + Name string + Database string + Schema string + Value string +} + +func ScanTag(row *sqlx.Row) (*tag, error) { + r := &tag{} + err := row.StructScan(r) + return r, err +} + +// ListTags returns a list of tags in a database or schema +func ListTags(databaseName, schemaName string, db *sql.DB) ([]tag, error) { + stmt := fmt.Sprintf(`SHOW TAGS IN SCHEMA "%v"."%v"`, databaseName, schemaName) + rows, err := Query(db, stmt) + if err != nil { + return nil, err + } + defer rows.Close() + + tags := []tag{} + err = sqlx.StructScan(rows, &tags) + if err == sql.ErrNoRows { + log.Printf("[DEBUG] no tags found") + return nil, nil + } + return tags, errors.Wrapf(err, "unable to scan row for %s", stmt) +} diff --git a/pkg/snowflake/tag_test.go b/pkg/snowflake/tag_test.go new file mode 100644 index 0000000000..5155dbf62f --- /dev/null +++ b/pkg/snowflake/tag_test.go @@ -0,0 +1,66 @@ +package snowflake + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTagCreate(t *testing.T) { + r := require.New(t) + o := Tag("test") + r.Equal(o.QualifiedName(), `"test"`) + + o.WithDB("db") + r.Equal(o.QualifiedName(), `"db"."test"`) + + o.WithSchema("schema") + r.Equal(o.QualifiedName(), `"db"."schema"."test"`) + + r.Equal(o.Create(), `CREATE TAG "db"."schema"."test"`) + + o.WithComment("Yee'haw") + r.Equal(`CREATE TAG "db"."schema"."test" COMMENT = 'Yee\'haw'`, o.Create()) +} + +func TestTagRename(t *testing.T) { + r := require.New(t) + o := Tag("test") + r.Equal(o.Rename("bob"), `ALTER TAG "test" RENAME TO "bob"`) +} + +func TestTagChangeComment(t *testing.T) { + r := require.New(t) + o := Tag("test") + r.Equal(`ALTER TAG "test" SET COMMENT = 'worst\' tag ever'`, o.ChangeComment("worst' tag ever")) +} + +func TestTagRemoveComment(t *testing.T) { + r := require.New(t) + o := Tag("test") + r.Equal(o.RemoveComment(), `ALTER TAG "test" UNSET COMMENT`) +} + +func TestTagDrop(t *testing.T) { + r := require.New(t) + o := Tag("test") + r.Equal(o.Drop(), `DROP TAG "test"`) +} + +func TestTagUndrop(t *testing.T) { + r := require.New(t) + o := Tag("test") + r.Equal(o.Undrop(), `UNDROP TAG "test"`) +} + +func TestTagShow(t *testing.T) { + r := require.New(t) + o := Tag("test") + r.Equal(o.Show(), `SHOW TAGS LIKE 'test'`) + + o.WithDB("db") + r.Equal(o.Show(), `SHOW TAGS LIKE 'test' IN DATABASE "db"`) + + o.WithSchema("schema") + r.Equal(o.Show(), `SHOW TAGS LIKE 'test' IN SCHEMA "db"."schema"`) +} diff --git a/pkg/snowflake/view.go b/pkg/snowflake/view.go index 1ccbac7e48..bac80b16ea 100644 --- a/pkg/snowflake/view.go +++ b/pkg/snowflake/view.go @@ -20,6 +20,7 @@ type ViewBuilder struct { replace bool comment string statement string + tags []TagValue } // QualifiedName prepends the db and schema if set and escapes everything nicely @@ -68,6 +69,30 @@ func (vb *ViewBuilder) WithStatement(s string) *ViewBuilder { return vb } +// WithTags sets the tags on the ViewBuilder +func (vb *ViewBuilder) WithTags(tags []TagValue) *ViewBuilder { + vb.tags = tags + return vb +} + +// AddTag returns the SQL query that will add a new tag to the view. +func (vb *ViewBuilder) AddTag(tag TagValue) string { + qn, _ := vb.QualifiedName() + return fmt.Sprintf(`ALTER VIEW %s SET TAG "%v"."%v"."%v" = "%v"`, qn, tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// ChangeTag returns the SQL query that will alter a tag on the view. +func (vb *ViewBuilder) ChangeTag(tag TagValue) string { + qn, _ := vb.QualifiedName() + return fmt.Sprintf(`ALTER VIEW %s SET TAG "%v"."%v"."%v" = "%v"`, qn, tag.Database, tag.Schema, tag.Name, tag.Value) +} + +// UnsetTag returns the SQL query that will unset a tag on the view. +func (vb *ViewBuilder) UnsetTag(tag TagValue) string { + qn, _ := vb.QualifiedName() + return fmt.Sprintf(`ALTER VIEW %s UNSET TAG "%v"."%v"."%v"`, qn, tag.Database, tag.Schema, tag.Name) +} + // View returns a pointer to a Builder that abstracts the DDL operations for a view. // // Supported DDL operations are: diff --git a/tools/tools.go b/tools/tools.go index d938b447eb..85365a02d8 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -1,3 +1,4 @@ +//go:build tools // +build tools package tools