diff --git a/docs/resources/table.md b/docs/resources/table.md index c72e5518a2..d018898c8c 100644 --- a/docs/resources/table.md +++ b/docs/resources/table.md @@ -44,6 +44,17 @@ resource "snowflake_table" "table" { } } + column { + name = "identity" + type = "NUMBER(38,0)" + nullable = true + + identity { + start_num = 1 + step_num = 3 + } + } + column { name = "data" type = "text" @@ -104,6 +115,7 @@ Optional: - **comment** (String) Column comment - **default** (Block List, Max: 1) Defines the column default value; note due to limitations of Snowflake's ALTER TABLE ADD/MODIFY COLUMN updates to default will not be applied (see [below for nested schema](#nestedblock--column--default)) +- **identity** (Block List, Max: 1) Defines the identity start/step values for a column. **Note** Identity/default are mutually exclusive. (see [below for nested schema](#nestedblock--column--identity)) - **nullable** (Boolean) Whether this column can contain null values. **Note**: Depending on your Snowflake version, the default value will not suffice if this column is used in a primary key constraint. @@ -116,6 +128,15 @@ Optional: - **sequence** (String) The default sequence to use for the column + +### Nested Schema for `column.identity` + +Optional: + +- **start_num** (Number) The number to start incrementing at. +- **step_num** (Number) Step size to increment by. + + ### Nested Schema for `primary_key` diff --git a/examples/resources/snowflake_table/resource.tf b/examples/resources/snowflake_table/resource.tf index bdae8d099d..7c58d68c55 100644 --- a/examples/resources/snowflake_table/resource.tf +++ b/examples/resources/snowflake_table/resource.tf @@ -29,6 +29,17 @@ resource "snowflake_table" "table" { } } + column { + name = "identity" + type = "NUMBER(38,0)" + nullable = true + + identity { + start_num = 1 + step_num = 3 + } + } + column { name = "data" type = "text" diff --git a/pkg/resources/table.go b/pkg/resources/table.go index 2756854964..3d9b2fb863 100644 --- a/pkg/resources/table.go +++ b/pkg/resources/table.go @@ -95,6 +95,32 @@ var tableSchema = map[string]*schema.Schema{ }, }, }, + /*Note: Identity and default are mutually exclusive. From what I can tell we can't enforce this here + the snowflake query will error so we can defer enforcement to there. + */ + "identity": { + Type: schema.TypeList, + Optional: true, + Description: "Defines the identity start/step values for a column. **Note** Identity/default are mutually exclusive.", + MinItems: 1, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "start_num": { + Type: schema.TypeInt, + Optional: true, + Description: "The number to start incrementing at.", + Default: 1, + }, + "step_num": { + Type: schema.TypeInt, + Optional: true, + Description: "Step size to increment by.", + Default: 1, + }, + }, + }, + }, "comment": { Type: schema.TypeString, Optional: true, @@ -251,11 +277,23 @@ func (cd *columnDefault) _type() string { return "unknown" } +type columnIdentity struct { + startNum int + stepNum int +} + +func (identity *columnIdentity) toSnowflakeColumnIdentity() *snowflake.ColumnIdentity { + snowIdentity := snowflake.ColumnIdentity{} + return snowIdentity.WithStartNum(identity.startNum).WithStep(identity.stepNum) + +} + type column struct { name string dataType string nullable bool _default *columnDefault + identity *columnIdentity comment string } @@ -266,6 +304,10 @@ func (c column) toSnowflakeColumn() snowflake.Column { sC = sC.WithDefault(c._default.toSnowflakeColumnDefault()) } + if c.identity != nil { + sC = sC.WithIdentity(c.identity.toSnowflakeColumnIdentity()) + } + return *sC.WithName(c.name). WithType(c.dataType). WithNullable(c.nullable). @@ -349,20 +391,38 @@ func getColumnDefault(def map[string]interface{}) *columnDefault { return nil } +func getColumnIdentity(identity map[string]interface{}) *columnIdentity { + if len(identity) > 0 { + + startNum := identity["start_num"].(int) + stepNum := identity["step_num"].(int) + return &columnIdentity{startNum, stepNum} + } + + return nil +} + func getColumn(from interface{}) (to column) { c := from.(map[string]interface{}) var cd *columnDefault + var id *columnIdentity _default := c["default"].([]interface{}) + identity := c["identity"].([]interface{}) + if len(_default) == 1 { cd = getColumnDefault(_default[0].(map[string]interface{})) } + if len(identity) == 1 { + id = getColumnIdentity(identity[0].(map[string]interface{})) + } return column{ name: c["name"].(string), dataType: c["type"].(string), nullable: c["nullable"].(bool), _default: cd, + identity: id, comment: c["comment"].(string), } } @@ -573,14 +633,17 @@ func UpdateTable(d *schema.ResourceData, meta interface{}) error { } for _, cA := range added { var q string - if cA._default == nil { - q = builder.AddColumn(cA.name, cA.dataType, cA.nullable, nil, cA.comment) + + if cA.identity == nil && cA._default == nil { + q = builder.AddColumn(cA.name, cA.dataType, cA.nullable, nil, nil, cA.comment) + } else if cA.identity != nil { + q = builder.AddColumn(cA.name, cA.dataType, cA.nullable, nil, cA.identity.toSnowflakeColumnIdentity(), cA.comment) } else { if cA._default._type() != "constant" { return fmt.Errorf("Failed to add column %v => Only adding a column as a constant is supported by Snowflake", cA.name) } - q = builder.AddColumn(cA.name, cA.dataType, cA.nullable, cA._default.toSnowflakeColumnDefault(), cA.comment) + q = builder.AddColumn(cA.name, cA.dataType, cA.nullable, cA._default.toSnowflakeColumnDefault(), nil, cA.comment) } err := snowflake.Exec(db, q) diff --git a/pkg/resources/table_acceptance_test.go b/pkg/resources/table_acceptance_test.go index 5a3c331aad..1b39218c80 100644 --- a/pkg/resources/table_acceptance_test.go +++ b/pkg/resources/table_acceptance_test.go @@ -1052,3 +1052,159 @@ resource "snowflake_table" "test_table" { ` return fmt.Sprintf(s, name, tagName, tag2Name) } + +func TestAcc_TableIdentity(t *testing.T) { + accName := strings.ToUpper(acctest.RandStringFromCharSet(10, acctest.CharSetAlpha)) + + resource.ParallelTest(t, resource.TestCase{ + Providers: providers(), + Steps: []resource.TestStep{ + { + Config: tableColumnWithIdentityDefault(accName), + 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", "data_retention_days", "1"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "change_tracking", "false"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "comment", "Terraform acceptance test"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.#", "3"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.0.name", "column1"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.0.type", "NUMBER(38,0)"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.0.type.default.0.expression"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.0.type.default.0.sequence"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.1.name", "column2"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.1.type", "TIMESTAMP_NTZ(9)"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.1.type.default.0.constant"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.1.type.default.0.sequence"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.2.name", "column3"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.2.type", "NUMBER(38,0)"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.2.type.default.0.constant"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.2.type.default.0.expression"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.2.identity.0.start_num", "1"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.2.identity.0.step_num", "1"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "primary_key"), + ), + }, + { + Config: tableColumnWithIdentity(accName), + 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", "data_retention_days", "1"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "change_tracking", "false"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "comment", "Terraform acceptance test"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.#", "3"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.0.name", "column1"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.0.type", "NUMBER(38,0)"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.0.type.default.0.expression"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.0.type.default.0.sequence"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.1.name", "column2"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.1.type", "TIMESTAMP_NTZ(9)"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.1.type.default.0.constant"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.1.type.default.0.sequence"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.2.type.default.0.constant"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.2.type.default.0.expression"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.0.identity.0.start_num"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "column.0.identity.0.step_num"), + // we've dropped the previous identity column and making sure that adding a new column as an identity works + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.2.identity.0.start_num", "2"), + resource.TestCheckResourceAttr("snowflake_table.test_table", "column.2.identity.0.step_num", "4"), + resource.TestCheckNoResourceAttr("snowflake_table.test_table", "primary_key"), + ), + }, + }, + }) +} + +func tableColumnWithIdentityDefault(name 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_seq" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "%s" +} + +resource "snowflake_table" "test_table" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "%s" + comment = "Terraform acceptance test" + + column { + name = "column1" + type = "NUMBER(38,0)" + } + column { + name = "column2" + type = "TIMESTAMP_NTZ(9)" + } + column { + name = "column3" + type = "NUMBER(38,0)" + identity { + } + } +} +` + return fmt.Sprintf(s, name, name, name, name) +} + +func tableColumnWithIdentity(name 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_seq" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "%s" +} + +resource "snowflake_table" "test_table" { + database = snowflake_database.test_database.name + schema = snowflake_schema.test_schema.name + name = "%s" + comment = "Terraform acceptance test" + + column { + name = "column1" + type = "NUMBER(38,0)" + } + column { + name = "column2" + type = "TIMESTAMP_NTZ(9)" + } + + column { + name = "column4" + type = "NUMBER(38,0)" + identity { + start_num = 2 + step_num = 4 + } + } +} +` + return fmt.Sprintf(s, name, name, name, name) +} diff --git a/pkg/snowflake/table.go b/pkg/snowflake/table.go index d7b4ca405c..ae533fd1a4 100644 --- a/pkg/snowflake/table.go +++ b/pkg/snowflake/table.go @@ -43,6 +43,22 @@ type ColumnDefault struct { expression string } +type ColumnIdentity struct { + startNum int + stepNum int +} + +func (id *ColumnIdentity) WithStartNum(start int) *ColumnIdentity { + id.startNum = start + return id + +} + +func (id *ColumnIdentity) WithStep(step int) *ColumnIdentity { + id.stepNum = step + return id +} + func NewColumnDefaultWithConstant(constant string) *ColumnDefault { return &ColumnDefault{ _type: columnDefaultTypeConstant, @@ -98,7 +114,8 @@ type Column struct { _type string // type is reserved nullable bool _default *ColumnDefault // default is reserved - comment string // pointer as value is nullable + identity *ColumnIdentity + comment string // pointer as value is nullable } // WithName set the column name @@ -130,6 +147,11 @@ func (c *Column) WithComment(comment string) *Column { return c } +func (c *Column) WithIdentity(id *ColumnIdentity) *Column { + c.identity = id + return c +} + func (c *Column) getColumnDefinition(withInlineConstraints bool, withComment bool) string { if c == nil { @@ -148,6 +170,10 @@ func (c *Column) getColumnDefinition(withInlineConstraints bool, withComment boo colDef.WriteString(fmt.Sprintf(` DEFAULT %v`, c._default.String(c._type))) } + if c.identity != nil { + colDef.WriteString(fmt.Sprintf(` IDENTITY(%v, %v)`, c.identity.startNum, c.identity.stepNum)) + } + if withComment { colDef.WriteString(fmt.Sprintf(` COMMENT '%v'`, EscapeString(c.comment))) } @@ -211,6 +237,7 @@ func NewColumns(tds []tableDescription) Columns { _type: td.Type.String, nullable: td.IsNullable(), _default: td.ColumnDefault(), + identity: td.ColumnIdentity(), comment: td.Comment.String, }) } @@ -240,6 +267,12 @@ func (c Columns) Flatten() []interface{} { flat["default"] = []interface{}{def} } + if col.identity != nil { + id := map[string]interface{}{} + id["start_num"] = col.identity.startNum + id["step_num"] = col.identity.stepNum + flat["identity"] = []interface{}{id} + } flattened = append(flattened, flat) } return flattened @@ -507,12 +540,13 @@ func (tb *TableBuilder) ChangeChangeTracking(changeTracking bool) string { } // AddColumn returns the SQL query that will add a new column to the table. -func (tb *TableBuilder) AddColumn(name string, dataType string, nullable bool, _default *ColumnDefault, comment string) string { +func (tb *TableBuilder) AddColumn(name string, dataType string, nullable bool, _default *ColumnDefault, identity *ColumnIdentity, comment string) string { col := Column{ name: name, _type: dataType, nullable: nullable, _default: _default, + identity: identity, comment: comment, } return fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s`, tb.QualifiedName(), col.getColumnDefinition(true, true)) @@ -647,9 +681,34 @@ func (td *tableDescription) ColumnDefault() *ColumnDefault { return NewColumnDefaultWithConstant(UnescapeSnowflakeString(td.Default.String)) } + if td.ColumnIdentity() != nil { + /* + Identity/autoincrement information is stored in the same column as default information. We want to handle the identity seperate so will return nil + here if identity information is present. Default/identity are mutually exclusive + */ + return nil + } + return NewColumnDefaultWithConstant(td.Default.String) } +func (td *tableDescription) ColumnIdentity() *ColumnIdentity { + // if autoincrement is used this is reflected back IDENTITY START 1 INCREMENT 1 + if !td.Default.Valid { + return nil + } + if strings.Contains(td.Default.String, "IDENTITY") { + + split := strings.Split(td.Default.String, " ") + start, _ := strconv.Atoi(split[2]) + step, _ := strconv.Atoi(split[4]) + + return &ColumnIdentity{start, step} + + } + return nil +} + type primaryKeyDescription struct { ColumnName sql.NullString `db:"column_name"` KeySequence sql.NullString `db:"key_sequence"` diff --git a/pkg/snowflake/table_test.go b/pkg/snowflake/table_test.go index 460b6e7623..32918d4f64 100644 --- a/pkg/snowflake/table_test.go +++ b/pkg/snowflake/table_test.go @@ -81,6 +81,35 @@ func TestTableCreate(t *testing.T) { 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 TestTableCreateIdentity(t *testing.T) { + r := require.New(t) + s := Table("test_table", "test_db", "test_schema") + cols := []Column{ + { + name: "column1", + _type: "OBJECT", + nullable: true, + }, + { + name: "column2", + _type: "VARCHAR", + nullable: true, + comment: "only populated when data is available", + }, + { + name: "column3", + _type: "NUMBER(38,0)", + nullable: false, + identity: &ColumnIdentity{2, 5}, + }, + } + + s.WithColumns(Columns(cols)) + 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 IDENTITY(2, 5) COMMENT '') DATA_RETENTION_TIME_IN_DAYS = 0 CHANGE_TRACKING = false`, s.Create()) +} + func TestTableChangeComment(t *testing.T) { r := require.New(t) s := Table("test_table", "test_db", "test_schema") @@ -96,19 +125,25 @@ func TestTableRemoveComment(t *testing.T) { func TestTableAddColumn(t *testing.T) { r := require.New(t) s := Table("test_table", "test_db", "test_schema") - r.Equal(s.AddColumn("new_column", "VARIANT", true, nil, ""), `ALTER TABLE "test_db"."test_schema"."test_table" ADD COLUMN "new_column" VARIANT COMMENT ''`) + r.Equal(s.AddColumn("new_column", "VARIANT", true, nil, nil, ""), `ALTER TABLE "test_db"."test_schema"."test_table" ADD COLUMN "new_column" VARIANT COMMENT ''`) } func TestTableAddColumnWithComment(t *testing.T) { r := require.New(t) s := Table("test_table", "test_db", "test_schema") - r.Equal(s.AddColumn("new_column", "VARIANT", true, nil, "some comment"), `ALTER TABLE "test_db"."test_schema"."test_table" ADD COLUMN "new_column" VARIANT COMMENT 'some comment'`) + r.Equal(s.AddColumn("new_column", "VARIANT", true, nil, nil, "some comment"), `ALTER TABLE "test_db"."test_schema"."test_table" ADD COLUMN "new_column" VARIANT COMMENT 'some comment'`) } func TestTableAddColumnWithDefault(t *testing.T) { r := require.New(t) s := Table("test_table", "test_db", "test_schema") - r.Equal(s.AddColumn("new_column", "NUMBER(38,0)", true, NewColumnDefaultWithConstant("1"), ""), `ALTER TABLE "test_db"."test_schema"."test_table" ADD COLUMN "new_column" NUMBER(38,0) DEFAULT 1 COMMENT ''`) + r.Equal(s.AddColumn("new_column", "NUMBER(38,0)", true, NewColumnDefaultWithConstant("1"), nil, ""), `ALTER TABLE "test_db"."test_schema"."test_table" ADD COLUMN "new_column" NUMBER(38,0) DEFAULT 1 COMMENT ''`) +} + +func TestTableAddColumnWithIdentity(t *testing.T) { + r := require.New(t) + s := Table("test_table", "test_db", "test_schema") + r.Equal(s.AddColumn("new_column", "NUMBER(38,0)", true, nil, &ColumnIdentity{1, 4}, ""), `ALTER TABLE "test_db"."test_schema"."test_table" ADD COLUMN "new_column" NUMBER(38,0) IDENTITY(1, 4) COMMENT ''`) } func TestTableDropColumn(t *testing.T) {