From 596316ccee52a52b6f4997b6a5a652ee771f33cf Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 13:33:50 +0200 Subject: [PATCH 1/8] fix: prevent type overwriting during resource import --- internal/services/column/resource.go | 13 ++++++++++--- internal/services/provider/resource.go | 7 ++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/services/column/resource.go b/internal/services/column/resource.go index 855b48d..5d3305d 100644 --- a/internal/services/column/resource.go +++ b/internal/services/column/resource.go @@ -731,9 +731,16 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON if key, ok := generic["key"].(string); ok { model.Key = types.StringValue(key) } - // Don't overwrite type from API — Appwrite returns internal type names - // (e.g. "double" for "float", "string" for "email"/"enum") which differ - // from the user-facing type names we use in the schema. + // When the type is already set (normal Read after Create/Update), don't overwrite it — + // Appwrite returns internal type names (e.g. "double" for "float", "string" for "email"/"enum") + // which differ from the user-facing type names we use in the schema. + // During import, however, the type is not yet in state, so we must populate it from the API + // response to avoid Terraform detecting a diff that forces replacement. + if model.Type.IsNull() || model.Type.IsUnknown() { + if apiType, ok := generic["type"].(string); ok { + model.Type = types.StringValue(apiType) + } + } if required, ok := generic["required"].(bool); ok { model.Required = types.BoolValue(required) } diff --git a/internal/services/provider/resource.go b/internal/services/provider/resource.go index 3171840..f7e4b8b 100644 --- a/internal/services/provider/resource.go +++ b/internal/services/provider/resource.go @@ -847,5 +847,10 @@ func (r *providerResource) mapToState(prov *models.Provider, model *providerReso model.Enabled = types.BoolValue(prov.Enabled) model.CreatedAt = types.StringValue(prov.CreatedAt) model.UpdatedAt = types.StringValue(prov.UpdatedAt) - // Don't overwrite type from API — preserve the user's value + // When the type is already set (normal Read after Create/Update), don't overwrite it + // to preserve the user's value. During import, however, the type is not yet in state, + // so we must populate it from the API to avoid Terraform detecting a diff that forces replacement. + if model.Type.IsNull() || model.Type.IsUnknown() { + model.Type = types.StringValue(prov.Type) + } } From 57e65f1fcc419c4bcb92080b47a29c967283631e Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 13:50:20 +0200 Subject: [PATCH 2/8] chore: add normalisation for string based types --- internal/common/helpers.go | 14 ++++++++++---- internal/services/column/resource.go | 29 +++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/internal/common/helpers.go b/internal/common/helpers.go index 7b227a2..45a8939 100644 --- a/internal/common/helpers.go +++ b/internal/common/helpers.go @@ -168,16 +168,22 @@ func CheckStringNotIgnored(planned types.String, actual string, attrName string, return AttrCheck{} } -// ImportColumnState parses a "database_id/table_id/key" import ID into state. +// ImportColumnState parses a "database_id/table_id/key" or "database_id/table_id/key/type" +// import ID into state. The optional type segment lets users specify the column type explicitly, +// which is necessary because Appwrite's API does not distinguish between some column types +// (e.g. varchar, text, longtext, and mediumtext all return type="string"). func ImportColumnState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - parts := strings.SplitN(req.ID, "/", 3) - if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { - resp.Diagnostics.AddError("Invalid import ID", fmt.Sprintf("Expected format: database_id/table_id/key, got: %s", req.ID)) + parts := strings.SplitN(req.ID, "/", 4) + if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + resp.Diagnostics.AddError("Invalid import ID", fmt.Sprintf("Expected format: database_id/table_id/key[/type], got: %s", req.ID)) return } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database_id"), parts[0])...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("table_id"), parts[1])...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("key"), parts[2])...) + if len(parts) == 4 && parts[3] != "" { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("type"), parts[3])...) + } } // WaitForDeploymentReady polls a deployment until its status becomes "ready", diff --git a/internal/services/column/resource.go b/internal/services/column/resource.go index 5d3305d..261fd35 100644 --- a/internal/services/column/resource.go +++ b/internal/services/column/resource.go @@ -736,9 +736,10 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON // which differ from the user-facing type names we use in the schema. // During import, however, the type is not yet in state, so we must populate it from the API // response to avoid Terraform detecting a diff that forces replacement. + // We normalize the API type+format back to the user-facing schema name. if model.Type.IsNull() || model.Type.IsUnknown() { if apiType, ok := generic["type"].(string); ok { - model.Type = types.StringValue(apiType) + model.Type = types.StringValue(normalizeAPIType(apiType, generic)) } } if required, ok := generic["required"].(bool); ok { @@ -832,3 +833,29 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON } } } + +// normalizeAPIType maps Appwrite's internal API type (and optional format) back +// to the user-facing schema type name used in the Terraform provider. +// For example, the API returns type="double" for float columns, and type="string" +// with format="email" for email columns. +func normalizeAPIType(apiType string, response map[string]interface{}) string { + switch apiType { + case "double": + return colTypeFloat + case "string": + format, _ := response["format"].(string) + switch format { + case "email": + return "email" + case "enum": + return "enum" + case "url": + return "url" + case "ip": + return "ip" + } + return "string" + default: + return apiType + } +} From ff474d0063fa3b28a0af66daf47b2ef138a15a8f Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 13:50:27 +0200 Subject: [PATCH 3/8] chore: add normalisation for string based types --- internal/services/column/resource.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/internal/services/column/resource.go b/internal/services/column/resource.go index 261fd35..acad9fc 100644 --- a/internal/services/column/resource.go +++ b/internal/services/column/resource.go @@ -731,17 +731,9 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON if key, ok := generic["key"].(string); ok { model.Key = types.StringValue(key) } - // When the type is already set (normal Read after Create/Update), don't overwrite it — - // Appwrite returns internal type names (e.g. "double" for "float", "string" for "email"/"enum") - // which differ from the user-facing type names we use in the schema. - // During import, however, the type is not yet in state, so we must populate it from the API - // response to avoid Terraform detecting a diff that forces replacement. - // We normalize the API type+format back to the user-facing schema name. - if model.Type.IsNull() || model.Type.IsUnknown() { - if apiType, ok := generic["type"].(string); ok { - model.Type = types.StringValue(normalizeAPIType(apiType, generic)) - } - } + // Don't overwrite type from API — Appwrite returns internal type names + // (e.g. "double" for "float", "string" for "email"/"enum") which differ + // from the user-facing type names we use in the schema. if required, ok := generic["required"].(bool); ok { model.Required = types.BoolValue(required) } From b8ff808b3f3e4b7f9c853d559c4ac6fedc9babcc Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 14:04:52 +0200 Subject: [PATCH 4/8] fix: prevent resource type overwriting during import and improve import ID validation --- internal/common/helpers.go | 29 +++++++++++++++++--------- internal/services/column/resource.go | 28 +------------------------ internal/services/provider/resource.go | 9 ++------ 3 files changed, 22 insertions(+), 44 deletions(-) diff --git a/internal/common/helpers.go b/internal/common/helpers.go index 45a8939..6fb960d 100644 --- a/internal/common/helpers.go +++ b/internal/common/helpers.go @@ -168,22 +168,31 @@ func CheckStringNotIgnored(planned types.String, actual string, attrName string, return AttrCheck{} } -// ImportColumnState parses a "database_id/table_id/key" or "database_id/table_id/key/type" -// import ID into state. The optional type segment lets users specify the column type explicitly, -// which is necessary because Appwrite's API does not distinguish between some column types -// (e.g. varchar, text, longtext, and mediumtext all return type="string"). +// RequiresReplaceExceptImport returns a plan modifier that forces resource replacement +// when the attribute value changes, but not when it's being set for the first time +// during import (where the prior state value is null). +func RequiresReplaceExceptImport() planmodifier.String { + return stringplanmodifier.RequiresReplaceIf( + func(_ context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { + // During import, the state value is null because the attribute wasn't + // previously tracked. Don't force replacement in that case. + resp.RequiresReplace = !req.StateValue.IsNull() + }, + "Requires replace unless the resource is being imported.", + "Requires replace unless the resource is being imported.", + ) +} + +// ImportColumnState parses a "database_id/table_id/key" import ID into state. func ImportColumnState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - parts := strings.SplitN(req.ID, "/", 4) - if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { - resp.Diagnostics.AddError("Invalid import ID", fmt.Sprintf("Expected format: database_id/table_id/key[/type], got: %s", req.ID)) + parts := strings.SplitN(req.ID, "/", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + resp.Diagnostics.AddError("Invalid import ID", fmt.Sprintf("Expected format: database_id/table_id/key, got: %s", req.ID)) return } resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database_id"), parts[0])...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("table_id"), parts[1])...) resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("key"), parts[2])...) - if len(parts) == 4 && parts[3] != "" { - resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("type"), parts[3])...) - } } // WaitForDeploymentReady polls a deployment until its status becomes "ready", diff --git a/internal/services/column/resource.go b/internal/services/column/resource.go index acad9fc..11c6f5a 100644 --- a/internal/services/column/resource.go +++ b/internal/services/column/resource.go @@ -91,7 +91,7 @@ func (r *columnResource) Schema(_ context.Context, _ resource.SchemaRequest, res "type": schema.StringAttribute{ Description: "The column type. One of: " + allColumnTypes + ".", Required: true, - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + PlanModifiers: []planmodifier.String{common.RequiresReplaceExceptImport()}, }, "required": schema.BoolAttribute{ Description: "Whether the column is required.", @@ -825,29 +825,3 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON } } } - -// normalizeAPIType maps Appwrite's internal API type (and optional format) back -// to the user-facing schema type name used in the Terraform provider. -// For example, the API returns type="double" for float columns, and type="string" -// with format="email" for email columns. -func normalizeAPIType(apiType string, response map[string]interface{}) string { - switch apiType { - case "double": - return colTypeFloat - case "string": - format, _ := response["format"].(string) - switch format { - case "email": - return "email" - case "enum": - return "enum" - case "url": - return "url" - case "ip": - return "ip" - } - return "string" - default: - return apiType - } -} diff --git a/internal/services/provider/resource.go b/internal/services/provider/resource.go index f7e4b8b..f06cc85 100644 --- a/internal/services/provider/resource.go +++ b/internal/services/provider/resource.go @@ -90,7 +90,7 @@ func (r *providerResource) Schema(_ context.Context, _ resource.SchemaRequest, r "type": schema.StringAttribute{ Description: "The provider type. One of: sendgrid, mailgun, smtp, resend, twilio, vonage, msg91, telesign, textmagic, apns, fcm.", Required: true, - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + PlanModifiers: []planmodifier.String{common.RequiresReplaceExceptImport()}, }, "enabled": schema.BoolAttribute{ Description: "Whether the provider is enabled.", @@ -847,10 +847,5 @@ func (r *providerResource) mapToState(prov *models.Provider, model *providerReso model.Enabled = types.BoolValue(prov.Enabled) model.CreatedAt = types.StringValue(prov.CreatedAt) model.UpdatedAt = types.StringValue(prov.UpdatedAt) - // When the type is already set (normal Read after Create/Update), don't overwrite it - // to preserve the user's value. During import, however, the type is not yet in state, - // so we must populate it from the API to avoid Terraform detecting a diff that forces replacement. - if model.Type.IsNull() || model.Type.IsUnknown() { - model.Type = types.StringValue(prov.Type) - } + // Don't overwrite type from API — preserve the user's value } From 187baa7eee2722fe4a1d147781de4bfd7f8005dd Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 14:20:28 +0200 Subject: [PATCH 5/8] fix: update resource import handling to prevent type overwriting and normalize API types --- internal/common/helpers.go | 15 ------- internal/services/column/resource.go | 57 ++++++++++++++++++++------ internal/services/provider/resource.go | 9 +++- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/internal/common/helpers.go b/internal/common/helpers.go index 6fb960d..7b227a2 100644 --- a/internal/common/helpers.go +++ b/internal/common/helpers.go @@ -168,21 +168,6 @@ func CheckStringNotIgnored(planned types.String, actual string, attrName string, return AttrCheck{} } -// RequiresReplaceExceptImport returns a plan modifier that forces resource replacement -// when the attribute value changes, but not when it's being set for the first time -// during import (where the prior state value is null). -func RequiresReplaceExceptImport() planmodifier.String { - return stringplanmodifier.RequiresReplaceIf( - func(_ context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) { - // During import, the state value is null because the attribute wasn't - // previously tracked. Don't force replacement in that case. - resp.RequiresReplace = !req.StateValue.IsNull() - }, - "Requires replace unless the resource is being imported.", - "Requires replace unless the resource is being imported.", - ) -} - // ImportColumnState parses a "database_id/table_id/key" import ID into state. func ImportColumnState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { parts := strings.SplitN(req.ID, "/", 3) diff --git a/internal/services/column/resource.go b/internal/services/column/resource.go index 11c6f5a..415673b 100644 --- a/internal/services/column/resource.go +++ b/internal/services/column/resource.go @@ -91,7 +91,7 @@ func (r *columnResource) Schema(_ context.Context, _ resource.SchemaRequest, res "type": schema.StringAttribute{ Description: "The column type. One of: " + allColumnTypes + ".", Required: true, - PlanModifiers: []planmodifier.String{common.RequiresReplaceExceptImport()}, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, }, "required": schema.BoolAttribute{ Description: "Whether the column is required.", @@ -731,9 +731,15 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON if key, ok := generic["key"].(string); ok { model.Key = types.StringValue(key) } - // Don't overwrite type from API — Appwrite returns internal type names - // (e.g. "double" for "float", "string" for "email"/"enum") which differ - // from the user-facing type names we use in the schema. + // Don't overwrite type when already set — preserve the user's value. + // During import, type is not yet in state, so populate it from the API. + // The API returns internal names that differ from the schema in some cases + // (e.g. "double" for "float", "linestring" for "line"), so we normalize. + if model.Type.IsNull() || model.Type.IsUnknown() { + if apiType, ok := generic["type"].(string); ok { + model.Type = types.StringValue(normalizeAPIColumnType(apiType, generic)) + } + } if required, ok := generic["required"].(bool); ok { model.Required = types.BoolValue(required) } @@ -767,6 +773,10 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON } if encrypt, ok := generic["encrypt"].(bool); ok { model.Encrypt = types.BoolValue(encrypt) + } else if model.Encrypt.IsNull() || model.Encrypt.IsUnknown() { + // The API only returns encrypt for string-like types. Default to false + // for other types so the state matches the schema default during import. + model.Encrypt = types.BoolValue(false) } if elements, ok := generic["elements"].([]interface{}); ok && len(elements) > 0 { strs := make([]string, len(elements)) @@ -811,17 +821,38 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON model.DefaultStr = types.StringNull() } case bool: - if !model.DefaultStr.IsNull() { - model.DefaultStr = types.StringValue(fmt.Sprintf("%t", v)) - } + model.DefaultStr = types.StringValue(fmt.Sprintf("%t", v)) case float64: - if !model.DefaultStr.IsNull() { - if model.Type.ValueString() == colTypeInteger { - model.DefaultStr = types.StringValue(fmt.Sprintf("%d", int64(v))) - } else { - model.DefaultStr = types.StringValue(fmt.Sprintf("%g", v)) - } + if model.Type.ValueString() == colTypeInteger { + model.DefaultStr = types.StringValue(fmt.Sprintf("%d", int64(v))) + } else { + model.DefaultStr = types.StringValue(fmt.Sprintf("%g", v)) } } } } + +// normalizeAPIColumnType maps an Appwrite API type back to the user-facing +// schema type. Most types are returned as-is, but a few differ: +// +// API "double" → schema "float" +// API "linestring" → schema "line" +// API "string" with format "email"/"enum"/"url"/"ip" → that format name +func normalizeAPIColumnType(apiType string, response map[string]interface{}) string { + switch apiType { + case "double": + return colTypeFloat + case "linestring": + return "line" + case "string": + if format, ok := response["format"].(string); ok { + switch format { + case "email", "enum", "url", "ip": + return format + } + } + return "string" + default: + return apiType + } +} diff --git a/internal/services/provider/resource.go b/internal/services/provider/resource.go index f06cc85..48e134c 100644 --- a/internal/services/provider/resource.go +++ b/internal/services/provider/resource.go @@ -90,7 +90,7 @@ func (r *providerResource) Schema(_ context.Context, _ resource.SchemaRequest, r "type": schema.StringAttribute{ Description: "The provider type. One of: sendgrid, mailgun, smtp, resend, twilio, vonage, msg91, telesign, textmagic, apns, fcm.", Required: true, - PlanModifiers: []planmodifier.String{common.RequiresReplaceExceptImport()}, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, }, "enabled": schema.BoolAttribute{ Description: "Whether the provider is enabled.", @@ -847,5 +847,10 @@ func (r *providerResource) mapToState(prov *models.Provider, model *providerReso model.Enabled = types.BoolValue(prov.Enabled) model.CreatedAt = types.StringValue(prov.CreatedAt) model.UpdatedAt = types.StringValue(prov.UpdatedAt) - // Don't overwrite type from API — preserve the user's value + // Don't overwrite type when already set — preserve the user's value. + // During import, type is not yet in state, so populate it from the API. + // Provider types (sendgrid, smtp, twilio, etc.) match between API and schema. + if model.Type.IsNull() || model.Type.IsUnknown() { + model.Type = types.StringValue(prov.Type) + } } From 403cb29b6b6c059f6b50a9a7744571fb84dc42f5 Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 14:28:00 +0200 Subject: [PATCH 6/8] chore: cleanup column types towards variables --- internal/services/column/resource.go | 33 ++++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/internal/services/column/resource.go b/internal/services/column/resource.go index 415673b..df95ef3 100644 --- a/internal/services/column/resource.go +++ b/internal/services/column/resource.go @@ -27,6 +27,11 @@ var ( const ( colTypeInteger = "integer" colTypeFloat = "float" + colTypeString = "string" + colTypeEnum = "enum" + colTypeEmail = "email" + colTypeURL = "url" + colTypeLine = "line" ) var allColumnTypes = "varchar, text, longtext, mediumtext, integer, float, boolean, enum, email, datetime, url, ip, point, line, polygon, relationship, string" @@ -214,7 +219,7 @@ func (r *columnResource) Create(ctx context.Context, req resource.CreateRequest, var responseJSON []byte switch columnType { - case "string": + case colTypeString: var opts []tablesdb.CreateTextColumnOption if !plan.DefaultStr.IsNull() { opts = append(opts, tablesdbClient.WithCreateTextColumnDefault(plan.DefaultStr.ValueString())) @@ -341,7 +346,7 @@ func (r *columnResource) Create(ctx context.Context, req resource.CreateRequest, responseJSON, _ = json.Marshal(col) } - case "enum": + case colTypeEnum: if plan.Elements.IsNull() { resp.Diagnostics.AddError("Missing attribute", "elements is required for enum columns") return @@ -362,7 +367,7 @@ func (r *columnResource) Create(ctx context.Context, req resource.CreateRequest, responseJSON, _ = json.Marshal(col) } - case "email": + case colTypeEmail: var opts []tablesdb.CreateEmailColumnOption if !plan.DefaultStr.IsNull() { opts = append(opts, tablesdbClient.WithCreateEmailColumnDefault(plan.DefaultStr.ValueString())) @@ -386,7 +391,7 @@ func (r *columnResource) Create(ctx context.Context, req resource.CreateRequest, responseJSON, _ = json.Marshal(col) } - case "url": + case colTypeURL: var opts []tablesdb.CreateUrlColumnOption if !plan.DefaultStr.IsNull() { opts = append(opts, tablesdbClient.WithCreateUrlColumnDefault(plan.DefaultStr.ValueString())) @@ -417,7 +422,7 @@ func (r *columnResource) Create(ctx context.Context, req resource.CreateRequest, responseJSON, _ = json.Marshal(col) } - case "line": + case colTypeLine: col, e := tablesdbClient.CreateLineColumn(databaseID, tableID, key, required) err = e if col != nil { @@ -539,7 +544,7 @@ func (r *columnResource) Update(ctx context.Context, req resource.UpdateRequest, var responseJSON []byte switch columnType { - case "string": + case colTypeString: var opts []tablesdb.UpdateTextColumnOption col, e := tablesdbClient.UpdateTextColumn(databaseID, tableID, key, required, defaultStr, opts...) err = e @@ -614,7 +619,7 @@ func (r *columnResource) Update(ctx context.Context, req resource.UpdateRequest, responseJSON, _ = json.Marshal(col) } - case "enum": + case colTypeEnum: var elements []string resp.Diagnostics.Append(plan.Elements.ElementsAs(ctx, &elements, false)...) if resp.Diagnostics.HasError() { @@ -626,7 +631,7 @@ func (r *columnResource) Update(ctx context.Context, req resource.UpdateRequest, responseJSON, _ = json.Marshal(col) } - case "email": + case colTypeEmail: col, e := tablesdbClient.UpdateEmailColumn(databaseID, tableID, key, required, defaultStr) err = e if col != nil { @@ -640,7 +645,7 @@ func (r *columnResource) Update(ctx context.Context, req resource.UpdateRequest, responseJSON, _ = json.Marshal(col) } - case "url": + case colTypeURL: col, e := tablesdbClient.UpdateUrlColumn(databaseID, tableID, key, required, defaultStr) err = e if col != nil { @@ -661,7 +666,7 @@ func (r *columnResource) Update(ctx context.Context, req resource.UpdateRequest, responseJSON, _ = json.Marshal(col) } - case "line": + case colTypeLine: col, e := tablesdbClient.UpdateLineColumn(databaseID, tableID, key, required) err = e if col != nil { @@ -843,15 +848,15 @@ func normalizeAPIColumnType(apiType string, response map[string]interface{}) str case "double": return colTypeFloat case "linestring": - return "line" - case "string": + return colTypeLine + case colTypeString: if format, ok := response["format"].(string); ok { switch format { - case "email", "enum", "url", "ip": + case colTypeEmail, colTypeEnum, colTypeURL, "ip": return format } } - return "string" + return colTypeString default: return apiType } From dbd5957f42c77b75d224cd8cc539596265354a7a Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 14:35:08 +0200 Subject: [PATCH 7/8] fix: prevent overwriting default string values during response normalization --- internal/services/column/resource.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/services/column/resource.go b/internal/services/column/resource.go index df95ef3..ff873d4 100644 --- a/internal/services/column/resource.go +++ b/internal/services/column/resource.go @@ -826,12 +826,16 @@ func (r *columnResource) readResponseIntoState(ctx context.Context, responseJSON model.DefaultStr = types.StringNull() } case bool: - model.DefaultStr = types.StringValue(fmt.Sprintf("%t", v)) + if !model.DefaultStr.IsNull() || model.DefaultStr.IsUnknown() { + model.DefaultStr = types.StringValue(fmt.Sprintf("%t", v)) + } case float64: - if model.Type.ValueString() == colTypeInteger { - model.DefaultStr = types.StringValue(fmt.Sprintf("%d", int64(v))) - } else { - model.DefaultStr = types.StringValue(fmt.Sprintf("%g", v)) + if !model.DefaultStr.IsNull() || model.DefaultStr.IsUnknown() { + if model.Type.ValueString() == colTypeInteger { + model.DefaultStr = types.StringValue(fmt.Sprintf("%d", int64(v))) + } else { + model.DefaultStr = types.StringValue(fmt.Sprintf("%g", v)) + } } } } From 49b52e1d8ab246f7dd9fff039cc451b31a348f2a Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 15:55:12 +0200 Subject: [PATCH 8/8] fix: add plan modifiers to ensure state is used for unknown attributes --- internal/services/column/resource.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/services/column/resource.go b/internal/services/column/resource.go index ff873d4..99dbaec 100644 --- a/internal/services/column/resource.go +++ b/internal/services/column/resource.go @@ -170,10 +170,16 @@ func (r *columnResource) Schema(_ context.Context, _ resource.SchemaRequest, res "created_at": schema.StringAttribute{ Description: "The column creation timestamp in ISO 8601 format.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "updated_at": schema.StringAttribute{ Description: "The column last update timestamp in ISO 8601 format.", Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, "project_id": common.ProjectIDAttribute(), },