From 77348dc7ee298300906628cb96d798882529d0ef Mon Sep 17 00:00:00 2001 From: Duncan Fedde Date: Wed, 23 Jun 2021 16:34:39 -0500 Subject: [PATCH] feat: Add a resource to manage sequences (#582) --- docs/resources/sequence.md | 51 +++++ .../resources/snowflake_sequence/resource.tf | 14 ++ go.sum | 4 - pkg/provider/provider.go | 1 + pkg/resources/helpers_test.go | 8 + pkg/resources/sequence.go | 200 ++++++++++++++++++ pkg/resources/sequence_acceptance_test.go | 115 ++++++++++ pkg/resources/sequence_test.go | 121 +++++++++++ pkg/snowflake/sequence.go | 109 ++++++++++ pkg/snowflake/sequence_test.go | 35 +++ 10 files changed, 654 insertions(+), 4 deletions(-) create mode 100644 docs/resources/sequence.md create mode 100644 examples/resources/snowflake_sequence/resource.tf create mode 100644 pkg/resources/sequence.go create mode 100644 pkg/resources/sequence_acceptance_test.go create mode 100644 pkg/resources/sequence_test.go create mode 100644 pkg/snowflake/sequence.go create mode 100644 pkg/snowflake/sequence_test.go diff --git a/docs/resources/sequence.md b/docs/resources/sequence.md new file mode 100644 index 0000000000..95e020c51a --- /dev/null +++ b/docs/resources/sequence.md @@ -0,0 +1,51 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "snowflake_sequence Resource - terraform-provider-snowflake" +subcategory: "" +description: |- + +--- + +# snowflake_sequence (Resource) + + + +## Example Usage + +```terraform +resource "snowflake_database" "database" { + name = "things" +} + +resource "snowflake_schema" "test_schema" { + name = "things" + database = snowflake_database.test_database.name +} + +resource "snowflake_sequence" "test_sequence" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "thing_counter" +} +``` + + +## Schema + +### Required + +- **database** (String) The database in which to create the sequence. Don't use the | character. +- **name** (String) Specifies the name for the sequence. +- **schema** (String) The schema in which to create the sequence. Don't use the | character. + +### Optional + +- **comment** (String) Specifies a comment for the sequence. +- **id** (String) The ID of this resource. +- **increment** (Number) The amount the sequence will increase by each time it is used + +### Read-Only + +- **next_value** (Number) The next value the sequence will provide. + + diff --git a/examples/resources/snowflake_sequence/resource.tf b/examples/resources/snowflake_sequence/resource.tf new file mode 100644 index 0000000000..d3754f3bd9 --- /dev/null +++ b/examples/resources/snowflake_sequence/resource.tf @@ -0,0 +1,14 @@ +resource "snowflake_database" "database" { + name = "things" +} + +resource "snowflake_schema" "test_schema" { + name = "things" + database = snowflake_database.test_database.name +} + +resource "snowflake_sequence" "test_sequence" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "thing_counter" +} diff --git a/go.sum b/go.sum index e9d207c53e..2d03c19ffc 100644 --- a/go.sum +++ b/go.sum @@ -1008,10 +1008,6 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.1.1 h1:wGiQel/hW0NnEkJUk8lbzkX2gFJU6PFxf1v5OlCfuOs= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2 h1:kRBLX7v7Af8W7Gdbbc908OJcdgtK8bOz9Uaj8/F1ACA= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3 h1:L69ShwSZEyCsLKoAxDKeMvLDZkumEe8gXUZAjab0tX8= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index e46c737fad..5cca7479df 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -169,6 +169,7 @@ func getResources() map[string]*schema.Resource { "snowflake_role_grants": resources.RoleGrants(), "snowflake_schema": resources.Schema(), "snowflake_scim_integration": resources.SCIMIntegration(), + "snowflake_sequence": resources.Sequence(), "snowflake_share": resources.Share(), "snowflake_stage": resources.Stage(), "snowflake_storage_integration": resources.StorageIntegration(), diff --git a/pkg/resources/helpers_test.go b/pkg/resources/helpers_test.go index f22956a8cb..512cbfadab 100644 --- a/pkg/resources/helpers_test.go +++ b/pkg/resources/helpers_test.go @@ -129,6 +129,14 @@ func resourceMonitor(t *testing.T, id string, params map[string]interface{}) *sc return d } +func sequence(t *testing.T, id string, params map[string]interface{}) *schema.ResourceData { + r := require.New(t) + d := schema.TestResourceDataRaw(t, resources.Sequence().Schema, params) + r.NotNil(d) + d.SetId(id) + return d +} + func share(t *testing.T, id string, params map[string]interface{}) *schema.ResourceData { r := require.New(t) d := schema.TestResourceDataRaw(t, resources.Share().Schema, params) diff --git a/pkg/resources/sequence.go b/pkg/resources/sequence.go new file mode 100644 index 0000000000..9ec2e44c3e --- /dev/null +++ b/pkg/resources/sequence.go @@ -0,0 +1,200 @@ +package resources + +import ( + "database/sql" + "fmt" + "log" + "strconv" + + "github.com/chanzuckerberg/terraform-provider-snowflake/pkg/snowflake" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pkg/errors" +) + +var sequenceSchema = map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Specifies the name for the sequence.", + }, + "comment": { + Type: schema.TypeString, + Optional: true, + Default: "", + Description: "Specifies a comment for the sequence.", + }, + "increment": { + Type: schema.TypeInt, + Optional: true, + Default: 1, + Description: "The amount the sequence will increase by each time it is used", + }, + "database": { + Type: schema.TypeString, + Required: true, + Description: "The database in which to create the sequence. Don't use the | character.", + }, + "schema": { + Type: schema.TypeString, + Required: true, + Description: "The schema in which to create the sequence. Don't use the | character.", + }, + "next_value": { + Type: schema.TypeInt, + Description: "The next value the sequence will provide.", + Computed: true, + }, +} + +var sequenceProperties = []string{"comment", "data_retention_time_in_days"} + +// Sequence returns a pointer to the resource representing a sequence +func Sequence() *schema.Resource { + return &schema.Resource{ + Create: CreateSequence, + Read: ReadSequence, + Delete: DeleteSequence, + Update: UpdateSequence, + + Schema: sequenceSchema, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + } +} + +// CreateSequence implements schema.CreateFunc +func CreateSequence(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + database := d.Get("database").(string) + schema := d.Get("schema").(string) + name := d.Get("name").(string) + + sq := snowflake.Sequence(name, database, schema) + + if i, ok := d.GetOk("increment"); ok { + sq.WithIncrement(i.(int)) + } + + if v, ok := d.GetOk("comment"); ok { + sq.WithComment(v.(string)) + } + + err := snowflake.Exec(db, sq.Create()) + if err != nil { + return errors.Wrapf(err, "error creating sequence") + } + + return ReadSequence(d, meta) +} + +// ReadSequence implements schema.ReadFunc +func ReadSequence(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + database := d.Get("database").(string) + schema := d.Get("schema").(string) + name := d.Get("name").(string) + + stmt := snowflake.Sequence(name, database, schema).Show() + row := snowflake.QueryRow(db, stmt) + + sequence, err := snowflake.ScanSequence(row) + + if err != nil { + if err == sql.ErrNoRows { + // If not found, mark resource to be removed from statefile during apply or refresh + log.Printf("[DEBUG] sequence (%s) not found", d.Id()) + d.SetId("") + return nil + } + return errors.Wrap(err, "unable to scan row for SHOW SEQUENCES") + } + + err = d.Set("schema", sequence.SchemaName.String) + if err != nil { + return err + } + + err = d.Set("database", sequence.DBName.String) + if err != nil { + return err + } + + err = d.Set("comment", sequence.Comment.String) + if err != nil { + return err + } + + i, err := strconv.ParseInt(sequence.Increment.String, 10, 64) + if err != nil { + return err + } + + err = d.Set("increment", i) + if err != nil { + return err + } + + i, err = strconv.ParseInt(sequence.NextValue.String, 10, 64) + if err != nil { + return err + } + + err = d.Set("next_value", i) + if err != nil { + return err + } + + d.SetId(fmt.Sprintf(`%v|%v|%v`, sequence.DBName.String, sequence.SchemaName.String, sequence.Name.String)) + if err != nil { + return err + } + + return err +} + +func UpdateSequence(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + database := d.Get("database").(string) + schema := d.Get("schema").(string) + name := d.Get("name").(string) + next := d.Get("next_value").(int) + + DeleteSequence(d, meta) + + sq := snowflake.Sequence(name, database, schema) + + if i, ok := d.GetOk("increment"); ok { + sq.WithIncrement(i.(int)) + } + + if v, ok := d.GetOk("comment"); ok { + sq.WithComment(v.(string)) + } + + sq.WithStart(next) + + err := snowflake.Exec(db, sq.Create()) + if err != nil { + return errors.Wrapf(err, "error creating sequence") + } + + return ReadSequence(d, meta) +} + +func DeleteSequence(d *schema.ResourceData, meta interface{}) error { + db := meta.(*sql.DB) + database := d.Get("database").(string) + schema := d.Get("schema").(string) + name := d.Get("name").(string) + + stmt := snowflake.Sequence(name, database, schema).Drop() + + err := snowflake.Exec(db, stmt) + if err != nil { + return errors.Wrapf(err, "error dropping sequence %s", name) + } + + d.SetId("") + return nil +} diff --git a/pkg/resources/sequence_acceptance_test.go b/pkg/resources/sequence_acceptance_test.go new file mode 100644 index 0000000000..a65bf7d1f0 --- /dev/null +++ b/pkg/resources/sequence_acceptance_test.go @@ -0,0 +1,115 @@ +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_Sequence(t *testing.T) { + accName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + accRename := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + + resource.ParallelTest(t, resource.TestCase{ + Providers: providers(), + Steps: []resource.TestStep{ + // CREATE + { + Config: sequenceConfig(accName, accName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "name", accName), + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "next_value", "1"), + ), + }, + // Set comment and rename + { + Config: sequenceConfigWithComment(accName, accRename, "look at me I am a comment"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "name", accRename), + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "comment", "look at me I am a comment"), + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "next_value", "1"), + ), + }, + // Unset comment and set increment + { + Config: sequenceConfigWithIncrement(accName, accName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "name", accName), + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "comment", ""), + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "next_value", "1"), + resource.TestCheckResourceAttr("snowflake_sequence.test_sequence", "increment", "32"), + ), + }, + }, + }) +} + +func sequenceConfigWithIncrement(name, sequenceName string) string { + s := ` +resource "snowflake_database" "test_database" { + name = "%s" + comment = "Terraform acceptance test" +} + +resource "snowflake_schema" "test_schema" { + name = "%s" + database = snowflake_database.test_database.name + comment = "Terraform acceptance test" +} + +resource "snowflake_sequence" "test_sequence" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "%s" + increment = 32 +} +` + return fmt.Sprintf(s, name, name, sequenceName) +} +func sequenceConfig(name, sequenceName string) string { + s := ` +resource "snowflake_database" "test_database" { + name = "%s" + comment = "Terraform acceptance test" +} + +resource "snowflake_schema" "test_schema" { + name = "%s" + database = snowflake_database.test_database.name + comment = "Terraform acceptance test" +} + +resource "snowflake_sequence" "test_sequence" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "%s" +} +` + return fmt.Sprintf(s, name, name, sequenceName) +} + +func sequenceConfigWithComment(name, sequenceName, comment string) string { + s := ` +resource "snowflake_database" "test_database" { + name = "%s" + comment = "Terraform acceptance test" +} + +resource "snowflake_schema" "test_schema" { + name = "%s" + database = snowflake_database.test_database.name + comment = "Terraform acceptance test" +} + +resource "snowflake_sequence" "test_sequence" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "%s" + comment = "%s" +} +` + return fmt.Sprintf(s, name, name, sequenceName, comment) +} diff --git a/pkg/resources/sequence_test.go b/pkg/resources/sequence_test.go new file mode 100644 index 0000000000..c0bcf84893 --- /dev/null +++ b/pkg/resources/sequence_test.go @@ -0,0 +1,121 @@ +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/testhelpers" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/require" +) + +func TestSequence(t *testing.T) { + r := require.New(t) + err := resources.Sequence().InternalValidate(provider.Provider().Schema, true) + r.NoError(err) +} + +func TestSequenceCreate(t *testing.T) { + r := require.New(t) + + in := map[string]interface{}{ + "name": "good_name", + "schema": "schema", + "database": "database", + "comment": "great comment", + } + d := schema.TestResourceDataRaw(t, resources.Sequence().Schema, in) + r.NotNil(d) + + WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) { + mock.ExpectExec(`CREATE SEQUENCE "database"."schema"."good_name" COMMENT = 'great comment`).WillReturnResult(sqlmock.NewResult(1, 1)) + + rows := sqlmock.NewRows([]string{ + "name", + "database_name", + "schema_name", + "next_value", + "interval", + "created_on", + "owner", + "comment", + }).AddRow( + "good_name", + "database", + "schema", + "25", + "1", + "created_on", + "owner", + "mock comment", + ) + mock.ExpectQuery(`SHOW SEQUENCES LIKE 'good_name' IN SCHEMA "database"."schema"`).WillReturnRows(rows) + err := resources.CreateSequence(d, db) + r.NoError(err) + r.Equal("database|schema|good_name", d.Id()) + }) +} + +func TestSequenceRead(t *testing.T) { + r := require.New(t) + in := map[string]interface{}{ + "name": "good_name", + "schema": "schema", + "database": "database", + } + + d := sequence(t, "good_name", in) + + WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{ + "name", + "database_name", + "schema_name", + "next_value", + "interval", + "created_on", + "owner", + "comment", + }).AddRow( + "good_name", + "database", + "schema", + "5", + "25", + "created_on", + "owner", + "mock comment", + ) + mock.ExpectQuery(`SHOW SEQUENCES LIKE 'good_name' IN SCHEMA "database"."schema"`).WillReturnRows(rows) + err := resources.ReadSequence(d, db) + r.NoError(err) + r.Equal("good_name", d.Get("name").(string)) + r.Equal("schema", d.Get("schema").(string)) + r.Equal("database", d.Get("database").(string)) + r.Equal("mock comment", d.Get("comment").(string)) + r.Equal(25, d.Get("increment").(int)) + r.Equal(5, d.Get("next_value").(int)) + r.Equal("database|schema|good_name", d.Id()) + }) +} + +func TestSequenceDelete(t *testing.T) { + r := require.New(t) + in := map[string]interface{}{ + "name": "drop_it", + "schema": "schema", + "database": "database", + } + + d := sequence(t, "drop_it", in) + + WithMockDb(t, func(db *sql.DB, mock sqlmock.Sqlmock) { + mock.ExpectExec(`DROP SEQUENCE "database"."schema"."drop_it"`).WillReturnResult(sqlmock.NewResult(1, 1)) + err := resources.DeleteSequence(d, db) + r.NoError(err) + r.Equal("", d.Id()) + }) +} diff --git a/pkg/snowflake/sequence.go b/pkg/snowflake/sequence.go new file mode 100644 index 0000000000..04925c0e5c --- /dev/null +++ b/pkg/snowflake/sequence.go @@ -0,0 +1,109 @@ +package snowflake + +import ( + "database/sql" + "fmt" + "log" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" +) + +// Sequence returns a pointer to a Builder for a sequence +func Sequence(name, db, schema string) *SequenceBuilder { + return &SequenceBuilder{ + name: name, + db: db, + schema: schema, + increment: 1, + start: 1, + } +} + +type sequence struct { + Name sql.NullString `db:"name"` + DBName sql.NullString `db:"database_name"` + SchemaName sql.NullString `db:"schema_name"` + NextValue sql.NullString `db:"next_value"` + Increment sql.NullString `db:"interval"` + CreatedOn sql.NullString `db:"created_on"` + Owner sql.NullString `db:"owner"` + Comment sql.NullString `db:"comment"` +} + +type SequenceBuilder struct { + name string + db string + schema string + increment int + comment string + start int +} + +// Drop returns the SQL query that will drop a sequence. +func (sb *SequenceBuilder) Drop() string { + return fmt.Sprintf(`DROP SEQUENCE %v`, sb.QualifiedName()) +} + +// Drop returns the SQL query that will drop a sequence. +func (sb *SequenceBuilder) Show() string { + return fmt.Sprintf(`SHOW SEQUENCES LIKE '%v' IN SCHEMA "%v"."%v"`, sb.name, sb.db, sb.schema) +} + +func (sb *SequenceBuilder) Create() string { + q := strings.Builder{} + q.WriteString(fmt.Sprintf(`CREATE SEQUENCE %v`, sb.QualifiedName())) + if sb.start != 1 { + q.WriteString(fmt.Sprintf(` START = %d`, sb.start)) + } + if sb.increment != 1 { + q.WriteString(fmt.Sprintf(` INCREMENT = %d`, sb.increment)) + } + if sb.comment != "" { + q.WriteString(fmt.Sprintf(` COMMENT = '%v'`, EscapeString(sb.comment))) + } + return q.String() +} + +func (sb *SequenceBuilder) WithComment(comment string) *SequenceBuilder { + sb.comment = comment + return sb +} + +func (sb *SequenceBuilder) WithIncrement(increment int) *SequenceBuilder { + sb.increment = increment + return sb +} + +func (sb *SequenceBuilder) WithStart(start int) *SequenceBuilder { + sb.start = start + return sb +} + +func (sb *SequenceBuilder) QualifiedName() string { + return fmt.Sprintf(`"%v"."%v"."%v"`, sb.db, sb.schema, sb.name) +} + +func ScanSequence(row *sqlx.Row) (*sequence, error) { + d := &sequence{} + e := row.StructScan(d) + return d, e +} + +func ListSequences(sdb *sqlx.DB) ([]sequence, error) { + stmt := "SHOW SEQUENCES" + rows, err := sdb.Queryx(stmt) + if err != nil { + return nil, err + } + defer rows.Close() + + dbs := []sequence{} + err = sqlx.StructScan(rows, &dbs) + if err == sql.ErrNoRows { + log.Printf("[DEBUG] no sequence found") + return nil, nil + } + return dbs, errors.Wrapf(err, "unable to scan row for %s", stmt) +} diff --git a/pkg/snowflake/sequence_test.go b/pkg/snowflake/sequence_test.go new file mode 100644 index 0000000000..1d0e599521 --- /dev/null +++ b/pkg/snowflake/sequence_test.go @@ -0,0 +1,35 @@ +package snowflake + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSequenceCreate(t *testing.T) { + r := require.New(t) + s := Sequence("test_sequence", "test_db", "test_schema") + + r.Equal(`"test_db"."test_schema"."test_sequence"`, s.QualifiedName()) + + r.Equal(`CREATE SEQUENCE "test_db"."test_schema"."test_sequence"`, s.Create()) + + s.WithComment("Test Comment") + r.Equal(`CREATE SEQUENCE "test_db"."test_schema"."test_sequence" COMMENT = 'Test Comment'`, s.Create()) + s.WithIncrement(5) + r.Equal(`CREATE SEQUENCE "test_db"."test_schema"."test_sequence" INCREMENT = 5 COMMENT = 'Test Comment'`, s.Create()) + s.WithStart(26) + r.Equal(`CREATE SEQUENCE "test_db"."test_schema"."test_sequence" START = 26 INCREMENT = 5 COMMENT = 'Test Comment'`, s.Create()) +} + +func TestSequenceDrop(t *testing.T) { + r := require.New(t) + s := Sequence("test_sequence", "test_db", "test_schema") + r.Equal(`DROP SEQUENCE "test_db"."test_schema"."test_sequence"`, s.Drop()) +} + +func TestSequenceShow(t *testing.T) { + r := require.New(t) + s := Sequence("test_sequence", "test_db", "test_schema") + r.Equal(`SHOW SEQUENCES LIKE 'test_sequence' IN SCHEMA "test_db"."test_schema"`, s.Show()) +}