This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/terraform-provider-paimon.git


The following commit(s) were added to refs/heads/main by this push:
     new 555bff5  Fix REST security and table schema contracts (#8)
555bff5 is described below

commit 555bff55d197db9a4f9fa2c13812447058dc920c
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Aug 24 15:27:37 2026 +0800

    Fix REST security and table schema contracts (#8)
---
 README.md                           |   7 +-
 docs/index.md                       |  28 +-
 docs/resources/table.md             |  13 +-
 examples/dlf-ecs/main.tf            |   3 +-
 examples/dlf-sts/main.tf            |   3 +-
 examples/dlf-token-file/main.tf     |   3 +-
 internal/client/client.go           |  34 +-
 internal/client/client_test.go      | 200 ++++++++++-
 internal/client/data_type.go        | 651 ++++++++++++++++++++++++++++++++++++
 internal/client/dlf_auth.go         |  37 +-
 internal/client/dlf_auth_test.go    |   9 +
 internal/client/models.go           |  14 +-
 internal/provider/provider_test.go  | 333 ++++++++++++++++++
 internal/provider/resource_table.go |   6 +-
 internal/provider/table_options.go  | 184 ++++++++++
 internal/provider/table_schema.go   | 112 ++++++-
 16 files changed, 1580 insertions(+), 57 deletions(-)

diff --git a/README.md b/README.md
index 5bbbbcd..7951224 100644
--- a/README.md
+++ b/README.md
@@ -115,11 +115,8 @@ terraform import paimon_table.events analytics.events
 Go 1.25 or newer is required.
 
 ```bash
-make fmt
-make test
-make build
+make check
 ```
 
 See [`docs/index.md`](docs/index.md) for the full provider configuration and
-resource notes. The source comparison and implementation tradeoffs are recorded
-in [`docs/design.md`](docs/design.md).
+resource notes.
diff --git a/docs/index.md b/docs/index.md
index 840878c..bd83a92 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -42,10 +42,36 @@ provider "paimon" {
 - `token_provider` (optional): `bear` for Bearer authentication or `dlf` for
   Alibaba Cloud DLF AK/STS signing. It is inferred when omitted.
 - `token` (optional, sensitive): token used by the `bear` provider.
+- `prefix` (optional): client catalog path prefix. A server override returned
+  by `/v1/config` takes precedence.
+- `headers` (optional, sensitive): additional REST request headers. The
+  provider-managed `Authorization` header takes precedence.
+- `dlf_region` (optional): region for DLF default signing. Standard DLF
+  endpoints allow it to be inferred.
+- `dlf_signing_algorithm` (optional): `default` for DLF VPC/default endpoints
+  or `openapi` for DLFNext endpoints. It is inferred from standard endpoints.
+- `dlf_access_key_id` and `dlf_access_key_secret` (optional, sensitive): a
+  static Alibaba Cloud access key pair.
+- `dlf_security_token` (optional, sensitive): STS token paired with static
+  access keys.
+- `dlf_token_loader` (optional): `local_file` for a rotating token file or
+  `ecs` for ECS RAM role credentials.
+- `dlf_token_path` (optional): path to the rotating AK/STS JSON file. Setting
+  it implies the `local_file` loader.
+- `dlf_ecs_metadata_url` (optional): compatible ECS metadata endpoint override.
+- `dlf_ecs_role_name` (optional): RAM role name. The `ecs` loader discovers it
+  when omitted.
+
+Exactly one DLF credential source may be configured: static AK/STS, a local
+token file, or an ECS RAM role. DLF Catalog requests also require `warehouse`.
+See the [static STS](../examples/dlf-sts/main.tf),
+[rotating token file](../examples/dlf-token-file/main.tf), and
+[ECS role](../examples/dlf-ecs/main.tf) examples.
 
 The provider first calls `/v1/config`, merges server defaults, client values,
 and server overrides in that order, and then uses the resulting `prefix` for
-catalog operations.
+catalog operations. Redirects are rejected so authentication headers and DLF
+signatures are never reused for a different URL.
 
 ## Resources and data sources
 
diff --git a/docs/resources/table.md b/docs/resources/table.md
index 84c32f1..9d0cea3 100644
--- a/docs/resources/table.md
+++ b/docs/resources/table.md
@@ -46,13 +46,20 @@ resource "paimon_table" "example" {
 ```
 
 Each field supports `id`, `name`, `type`, `nullable`, `description`, and
-`default_value`. Field IDs are assigned by list position when omitted. Use
+`default_value`. Field IDs must be unique integers from 0 through 1073741822;
+the next available ID is assigned when omitted. Use
 canonical Paimon SQL type strings such as `INT`, `BIGINT`, `STRING`,
 `DECIMAL(12, 2)`, `ARRAY<STRING>`, or `ROW<item STRING>`.
 
 `database`, `name`, `fields`, `partition_keys`, and `primary_keys` are
-replacement attributes. `options` and `comment` update in place. Unmanaged
-server options are preserved and exposed through `server_options`.
+replacement attributes. Configure keys with `primary_keys` and
+`partition_keys`; the normalized Paimon options `primary-key` and `partition`
+are rejected in `options` because the server removes them from its options map.
+
+Mutable `options` and `comment` update in place. Changing or removing an option
+that Paimon defines as immutable, such as `merge-engine`, `bucket-key`, `type`,
+or `primary-key.nullable`, replaces the table. Unmanaged server options are
+preserved and exposed through `server_options`.
 
 Dropping a managed table can delete its data. Use `prevent_destroy` where
 appropriate:
diff --git a/examples/dlf-ecs/main.tf b/examples/dlf-ecs/main.tf
index bba0a06..44fd1bb 100644
--- a/examples/dlf-ecs/main.tf
+++ b/examples/dlf-ecs/main.tf
@@ -22,7 +22,8 @@ terraform {
 }
 
 provider "paimon" {
-  uri              = "https://dlf.cn-hangzhou.aliyuncs.com";
+  uri              = "https://dlfnext.cn-hangzhou.aliyuncs.com";
+  warehouse        = "my_catalog"
   token_provider   = "dlf"
   dlf_token_loader = "ecs"
 
diff --git a/examples/dlf-sts/main.tf b/examples/dlf-sts/main.tf
index b56bc95..9e65fc7 100644
--- a/examples/dlf-sts/main.tf
+++ b/examples/dlf-sts/main.tf
@@ -38,7 +38,8 @@ variable "dlf_security_token" {
 }
 
 provider "paimon" {
-  uri            = "https://dlf.cn-hangzhou.aliyuncs.com";
+  uri            = "https://dlfnext.cn-hangzhou.aliyuncs.com";
+  warehouse      = "my_catalog"
   token_provider = "dlf"
 
   dlf_access_key_id     = var.dlf_access_key_id
diff --git a/examples/dlf-token-file/main.tf b/examples/dlf-token-file/main.tf
index 611323e..132cd77 100644
--- a/examples/dlf-token-file/main.tf
+++ b/examples/dlf-token-file/main.tf
@@ -22,7 +22,8 @@ terraform {
 }
 
 provider "paimon" {
-  uri              = "https://dlf.cn-hangzhou.aliyuncs.com";
+  uri              = "https://dlfnext.cn-hangzhou.aliyuncs.com";
+  warehouse        = "my_catalog"
   token_provider   = "dlf"
   dlf_token_loader = "local_file"
   dlf_token_path   = "/run/secrets/dlf-sts.json"
diff --git a/internal/client/client.go b/internal/client/client.go
index 24310bf..bc64165 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -32,7 +32,10 @@ import (
        "time"
 )
 
-const userAgent = "terraform-provider-paimon"
+const (
+       userAgent              = "terraform-provider-paimon"
+       maxAPIResponseBodySize = 16 << 20
+)
 
 type Config struct {
        URI          string
@@ -96,10 +99,7 @@ func New(config Config) (*Client, error) {
                return nil, errors.New("Paimon REST URI must not include a 
query or fragment")
        }
 
-       httpClient := config.HTTPClient
-       if httpClient == nil {
-               httpClient = &http.Client{Timeout: 30 * time.Second}
-       }
+       httpClient := noRedirectHTTPClient(config.HTTPClient)
 
        configuredAuth := 
strings.ToLower(strings.TrimSpace(config.AuthProvider))
        if configuredAuth == "" && config.Token != "" {
@@ -311,18 +311,36 @@ func (c *Client) doRaw(ctx context.Context, method 
string, segments []string, qu
                return apiErr
        }
 
-       if result == nil || response.StatusCode == http.StatusNoContent {
-               _, _ = io.Copy(io.Discard, response.Body)
+       contents, err := io.ReadAll(io.LimitReader(response.Body, 
maxAPIResponseBodySize+1))
+       if err != nil {
+               return errors.New("read Paimon REST response")
+       }
+       if len(contents) > maxAPIResponseBodySize {
+               return errors.New("Paimon REST response exceeded 16 MiB size 
limit")
+       }
 
+       if result == nil || response.StatusCode == http.StatusNoContent {
                return nil
        }
-       if err := json.NewDecoder(response.Body).Decode(result); err != nil {
+       if err := json.Unmarshal(contents, result); err != nil {
                return fmt.Errorf("decode Paimon REST response: %w", err)
        }
 
        return nil
 }
 
+func noRedirectHTTPClient(input *http.Client) *http.Client {
+       if input == nil {
+               input = &http.Client{Timeout: 30 * time.Second}
+       }
+       output := *input
+       output.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
+               return errors.New("Paimon REST API redirects are not allowed")
+       }
+
+       return &output
+}
+
 func (c *Client) endpoint(segments []string, query url.Values) string {
        endpoint := *c.baseURL
        escapedPath := strings.TrimRight(endpoint.EscapedPath(), "/")
diff --git a/internal/client/client_test.go b/internal/client/client_test.go
index 2d684ea..dac103d 100644
--- a/internal/client/client_test.go
+++ b/internal/client/client_test.go
@@ -22,6 +22,7 @@ import (
        "encoding/json"
        "net/http"
        "net/http/httptest"
+       "strings"
        "sync/atomic"
        "testing"
 
@@ -140,7 +141,144 @@ func TestDataTypeStructuredJSON(t *testing.T) {
 
        encoded, err := json.Marshal(dataType)
        require.NoError(t, err)
-       assert.JSONEq(t, `"ROW<item ARRAY<STRING NOT NULL>> NOT NULL"`, 
string(encoded))
+       assert.JSONEq(t, `{
+               "type":"ROW NOT NULL",
+               
"fields":[{"id":0,"name":"item","type":{"type":"ARRAY","element":"STRING NOT 
NULL"}}]
+       }`, string(encoded))
+}
+
+func TestSchemaMarshalAssignsUniqueNestedFieldIDs(t *testing.T) {
+       schema := Schema{
+               Fields: []Field{
+                       {ID: 2, Name: "id", Type: DataType("BIGINT NOT NULL")},
+                       {
+                               ID:   7,
+                               Name: "payload",
+                               Type: DataType("ROW<`item name` 
ARRAY<MAP<STRING NOT NULL, ROW<value VECTOR<DOUBLE, 3>>>> COMMENT 'item''s 
label' DEFAULT CAST(NULL AS STRING)>"),
+                       },
+               },
+       }
+
+       encoded, err := json.Marshal(schema)
+       require.NoError(t, err)
+       assert.JSONEq(t, `{
+               "fields":[
+                       {"id":2,"name":"id","type":"BIGINT NOT NULL"},
+                       {"id":7,"name":"payload","type":{
+                               "type":"ROW",
+                               "fields":[{
+                                       "id":8,
+                                       "name":"item name",
+                                       
"type":{"type":"ARRAY","element":{"type":"MAP","key":"STRING NOT 
NULL","value":{"type":"ROW","fields":[{"id":9,"name":"value","type":{"type":"VECTOR","element":"DOUBLE","length":3}}]}}},
+                                       "description":"item's label",
+                                       "defaultValue":"CAST(NULL AS STRING)"
+                               }]
+                       }}
+               ],
+               "partitionKeys":[],
+               "primaryKeys":[],
+               "options":{}
+       }`, string(encoded))
+}
+
+func TestDataTypeMarshalRejectsMalformedComposite(t *testing.T) {
+       _, err := json.Marshal(DataType("MAP<STRING>"))
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "expected key and value types")
+}
+
+func TestDataTypeMarshalSupportsEmptyRow(t *testing.T) {
+       encoded, err := json.Marshal(DataType("ROW<> NOT NULL"))
+       require.NoError(t, err)
+       assert.JSONEq(t, `{"type":"ROW NOT NULL","fields":[]}`, string(encoded))
+}
+
+func TestDataTypeMarshalKeepsComparisonsInNestedDefaults(t *testing.T) {
+       for _, test := range []struct {
+               name           string
+               dataType       DataType
+               greaterDefault string
+               lesserDefault  string
+       }{
+               {
+                       name:           "parenthesized",
+                       dataType:       DataType("ROW<greater BOOLEAN DEFAULT 
(1 > 0), lesser BOOLEAN DEFAULT (1 < 2)>"),
+                       greaterDefault: "(1 > 0)",
+                       lesserDefault:  "(1 < 2)",
+               },
+               {
+                       name:           "unparenthesized",
+                       dataType:       DataType("ROW<greater BOOLEAN DEFAULT 1 
> 0, lesser BOOLEAN DEFAULT 1 < 2>"),
+                       greaterDefault: "1 > 0",
+                       lesserDefault:  "1 < 2",
+               },
+               {
+                       name:           "opaque composite keyword",
+                       dataType:       DataType("ROW<greater BOOLEAN DEFAULT 
MAP > 0, lesser BOOLEAN DEFAULT MAP < 2>"),
+                       greaterDefault: "MAP > 0",
+                       lesserDefault:  "MAP < 2",
+               },
+       } {
+               t.Run(test.name, func(t *testing.T) {
+                       encoded, err := json.Marshal(test.dataType)
+                       require.NoError(t, err)
+                       var structured struct {
+                               Fields []struct {
+                                       DefaultValue string 
`json:"defaultValue"`
+                               } `json:"fields"`
+                       }
+                       require.NoError(t, json.Unmarshal(encoded, &structured))
+                       require.Len(t, structured.Fields, 2)
+                       assert.Equal(t, test.greaterDefault, 
structured.Fields[0].DefaultValue)
+                       assert.Equal(t, test.lesserDefault, 
structured.Fields[1].DefaultValue)
+               })
+       }
+}
+
+func TestEquivalentDataTypesNormalizesCompositeSpelling(t *testing.T) {
+       assert.True(t, EquivalentDataTypes(DataType("MAP<STRING,STRING>"), 
DataType("MAP<STRING, STRING>")))
+       assert.True(t, EquivalentDataTypes(DataType("ROW<`item` STRING>"), 
DataType("ROW<item STRING>")))
+       assert.False(t, EquivalentDataTypes(DataType("MAP<STRING, STRING>"), 
DataType("MAP<STRING, BIGINT>")))
+}
+
+func TestDataTypeMarshalKeepsComparisonInNestedRowDefault(t *testing.T) {
+       encoded, err := json.Marshal(DataType("ROW<nested ROW<flag BOOLEAN 
DEFAULT 1 > 0>, tail BOOLEAN DEFAULT 1 < 2>"))
+       require.NoError(t, err)
+       assert.JSONEq(t, `{
+               "type":"ROW",
+               "fields":[
+                       {"id":0,"name":"nested","type":{"type":"ROW","fields":[
+                               
{"id":1,"name":"flag","type":"BOOLEAN","defaultValue":"1 > 0"}
+                       ]}},
+                       
{"id":2,"name":"tail","type":"BOOLEAN","defaultValue":"1 < 2"}
+               ]
+       }`, string(encoded))
+}
+
+func TestSchemaMarshalRejectsInvalidFieldIDs(t *testing.T) {
+       _, err := json.Marshal(Schema{Fields: []Field{
+               {ID: maxPaimonFieldID, Name: "max", Type: DataType("STRING")},
+       }})
+       require.NoError(t, err)
+
+       _, err = json.Marshal(Schema{Fields: []Field{
+               {ID: 1, Name: "first", Type: DataType("STRING")},
+               {ID: 1, Name: "duplicate", Type: DataType("STRING")},
+       }})
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "field ID 1 is duplicated")
+
+       _, err = json.Marshal(Schema{Fields: []Field{
+               {ID: maxPaimonFieldID + 1, Name: "reserved", Type: 
DataType("STRING")},
+       }})
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "must be between 0 and")
+
+       _, err = json.Marshal(Schema{Fields: []Field{
+               {ID: maxPaimonFieldID, Name: "row", Type: DataType("ROW<nested 
STRING>")},
+       }})
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), "nested field IDs exceed")
 }
 
 func TestNewRejectsInvalidURI(t *testing.T) {
@@ -148,6 +286,66 @@ func TestNewRejectsInvalidURI(t *testing.T) {
        require.EqualError(t, err, "Paimon REST URI must use http or https")
 }
 
+func TestClientRejectsRedirectWithoutForwardingCredentials(t *testing.T) {
+       var targetCalls atomic.Int32
+       target := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               targetCalls.Add(1)
+               assert.Empty(t, r.Header.Get("Authorization"))
+               w.WriteHeader(http.StatusOK)
+       }))
+       defer target.Close()
+
+       redirect := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               http.Redirect(w, r, target.URL+r.URL.Path, 
http.StatusTemporaryRedirect)
+       }))
+       defer redirect.Close()
+
+       for name, config := range map[string]Config{
+               "bearer": {URI: redirect.URL, Token: "must-not-leak"},
+               "dlf": {
+                       URI:          redirect.URL,
+                       AuthProvider: AuthProviderDLF,
+                       DLF: &DLFConfig{
+                               Region:           "cn-hangzhou",
+                               AccessKeyID:      "must-not-leak-id",
+                               AccessKeySecret:  "must-not-leak-secret",
+                               SecurityToken:    "must-not-leak-sts",
+                               SigningAlgorithm: DLFSigningDefault,
+                       },
+               },
+       } {
+               t.Run(name, func(t *testing.T) {
+                       api, err := New(config)
+                       require.NoError(t, err)
+                       _, err = api.GetDatabase(context.Background(), 
"analytics")
+                       require.Error(t, err)
+                       assert.Contains(t, err.Error(), "redirects are not 
allowed")
+                       assert.NotContains(t, err.Error(), "must-not-leak")
+                       assert.Equal(t, int32(0), targetCalls.Load())
+               })
+       }
+}
+
+func TestClientRejectsOversizedSuccessfulResponse(t *testing.T) {
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               if r.URL.Path == "/v1/config" {
+                       writeJSON(t, w, ConfigResponse{Defaults: 
map[string]string{"prefix": "catalog"}})
+
+                       return
+               }
+               w.Header().Set("Content-Type", "application/json")
+               _, _ = w.Write([]byte(`{"name":"`))
+               _, _ = w.Write([]byte(strings.Repeat("x", 
maxAPIResponseBodySize)))
+               _, _ = w.Write([]byte(`"}`))
+       }))
+       defer server.Close()
+
+       api, err := New(Config{URI: server.URL})
+       require.NoError(t, err)
+       _, err = api.GetDatabase(context.Background(), "analytics")
+       require.EqualError(t, err, "Paimon REST response exceeded 16 MiB size 
limit")
+}
+
 func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
        t.Helper()
        w.Header().Set("Content-Type", "application/json")
diff --git a/internal/client/data_type.go b/internal/client/data_type.go
new file mode 100644
index 0000000..897c9c6
--- /dev/null
+++ b/internal/client/data_type.go
@@ -0,0 +1,651 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "strconv"
+       "strings"
+       "unicode"
+)
+
+// Paimon reserves IDs at and above SpecialFields.SYSTEM_FIELD_ID_START.
+const maxPaimonFieldID = (1 << 30) - 2
+
+type structuredDataType struct {
+       Type    string             `json:"type"`
+       Element any                `json:"element,omitempty"`
+       Key     any                `json:"key,omitempty"`
+       Value   any                `json:"value,omitempty"`
+       Fields  *[]structuredField `json:"fields,omitempty"`
+       Length  int                `json:"length,omitempty"`
+}
+
+type structuredField struct {
+       ID           int     `json:"id"`
+       Name         string  `json:"name"`
+       Type         any     `json:"type"`
+       Description  *string `json:"description,omitempty"`
+       DefaultValue *string `json:"defaultValue,omitempty"`
+}
+
+// MarshalJSON coordinates nested ROW field IDs across the complete schema.
+// Paimon requires nested fields to carry IDs when their parent field has one.
+func (s Schema) MarshalJSON() ([]byte, error) {
+       type schemaJSON struct {
+               Fields        []structuredField `json:"fields"`
+               PartitionKeys []string          `json:"partitionKeys"`
+               PrimaryKeys   []string          `json:"primaryKeys"`
+               Options       map[string]string `json:"options"`
+               Comment       *string           `json:"comment,omitempty"`
+       }
+
+       nextFieldID := -1
+       usedFieldIDs := make(map[int]struct{}, len(s.Fields))
+       for _, field := range s.Fields {
+               if field.ID < 0 || field.ID > maxPaimonFieldID {
+                       return nil, fmt.Errorf("Paimon field %q ID must be 
between 0 and %d", field.Name, maxPaimonFieldID)
+               }
+               if _, duplicate := usedFieldIDs[field.ID]; duplicate {
+                       return nil, fmt.Errorf("Paimon field ID %d is 
duplicated", field.ID)
+               }
+               usedFieldIDs[field.ID] = struct{}{}
+               if field.ID > nextFieldID {
+                       nextFieldID = field.ID
+               }
+       }
+       fields := make([]structuredField, 0, len(s.Fields))
+       for _, field := range s.Fields {
+               encodedType, err := encodeDataType(string(field.Type), 
&nextFieldID)
+               if err != nil {
+                       return nil, fmt.Errorf("encode Paimon field %q type: 
%w", field.Name, err)
+               }
+               fields = append(fields, structuredField{
+                       ID:           field.ID,
+                       Name:         field.Name,
+                       Type:         encodedType,
+                       Description:  field.Description,
+                       DefaultValue: field.DefaultValue,
+               })
+       }
+
+       return json.Marshal(schemaJSON{
+               Fields:        fields,
+               PartitionKeys: nonNilSlice(s.PartitionKeys),
+               PrimaryKeys:   nonNilSlice(s.PrimaryKeys),
+               Options:       nonNilMap(s.Options),
+               Comment:       s.Comment,
+       })
+}
+
+func encodeDataType(input string, nextFieldID *int) (any, error) {
+       typeName, notNull := stripNotNull(input)
+       root, body, composite, err := compositeTypeParts(typeName)
+       if err != nil {
+               return nil, err
+       }
+       if !composite {
+               return strings.TrimSpace(input), nil
+       }
+
+       serializedRoot := root
+       if notNull {
+               serializedRoot += " NOT NULL"
+       }
+       parts, err := splitTopLevel(body, ',')
+       if err != nil {
+               return nil, fmt.Errorf("invalid %s type %q: %w", root, input, 
err)
+       }
+
+       switch root {
+       case "ARRAY", "MULTISET":
+               if len(parts) != 1 || strings.TrimSpace(parts[0]) == "" {
+                       return nil, fmt.Errorf("invalid %s type %q: expected 
one element type", root, input)
+               }
+               element, err := encodeDataType(parts[0], nextFieldID)
+               if err != nil {
+                       return nil, err
+               }
+
+               return structuredDataType{Type: serializedRoot, Element: 
element}, nil
+       case "MAP":
+               if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || 
strings.TrimSpace(parts[1]) == "" {
+                       return nil, fmt.Errorf("invalid MAP type %q: expected 
key and value types", input)
+               }
+               key, err := encodeDataType(parts[0], nextFieldID)
+               if err != nil {
+                       return nil, err
+               }
+               value, err := encodeDataType(parts[1], nextFieldID)
+               if err != nil {
+                       return nil, err
+               }
+
+               return structuredDataType{Type: serializedRoot, Key: key, 
Value: value}, nil
+       case "VECTOR":
+               if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
+                       return nil, fmt.Errorf("invalid VECTOR type %q: 
expected element type and length", input)
+               }
+               element, err := encodeDataType(parts[0], nextFieldID)
+               if err != nil {
+                       return nil, err
+               }
+               length, err := strconv.Atoi(strings.TrimSpace(parts[1]))
+               if err != nil || length <= 0 {
+                       return nil, fmt.Errorf("invalid VECTOR type %q: length 
must be a positive integer", input)
+               }
+
+               return structuredDataType{Type: serializedRoot, Element: 
element, Length: length}, nil
+       case "ROW":
+               fields := make([]structuredField, 0, len(parts))
+               if len(parts) == 1 && parts[0] == "" {
+                       return structuredDataType{Type: serializedRoot, Fields: 
&fields}, nil
+               }
+               for _, part := range parts {
+                       name, fieldType, description, defaultValue, err := 
parseRowField(part)
+                       if err != nil {
+                               return nil, fmt.Errorf("invalid ROW type %q: 
%w", input, err)
+                       }
+                       if *nextFieldID >= maxPaimonFieldID {
+                               return nil, fmt.Errorf("Paimon nested field IDs 
exceed %d", maxPaimonFieldID)
+                       }
+                       *nextFieldID = *nextFieldID + 1
+                       fieldID := *nextFieldID
+                       encodedType, err := encodeDataType(fieldType, 
nextFieldID)
+                       if err != nil {
+                               return nil, err
+                       }
+                       fields = append(fields, structuredField{
+                               ID:           fieldID,
+                               Name:         name,
+                               Type:         encodedType,
+                               Description:  description,
+                               DefaultValue: defaultValue,
+                       })
+               }
+
+               return structuredDataType{Type: serializedRoot, Fields: 
&fields}, nil
+       default:
+               return nil, fmt.Errorf("unsupported composite Paimon type %q", 
root)
+       }
+}
+
+// EquivalentDataTypes reports whether two SQL spellings encode to the same
+// language-neutral Paimon REST data type.
+func EquivalentDataTypes(left, right DataType) bool {
+       canonicalLeft, err := canonicalDataType(left)
+       if err != nil {
+               return false
+       }
+       canonicalRight, err := canonicalDataType(right)
+
+       return err == nil && canonicalLeft == canonicalRight
+}
+
+func canonicalDataType(value DataType) (DataType, error) {
+       encoded, err := json.Marshal(value)
+       if err != nil {
+               return "", err
+       }
+       var canonical DataType
+       if err := json.Unmarshal(encoded, &canonical); err != nil {
+               return "", err
+       }
+
+       return canonical, nil
+}
+
+func stripNotNull(input string) (string, bool) {
+       trimmed := strings.TrimSpace(input)
+       const suffix = " NOT NULL"
+       if len(trimmed) >= len(suffix) && 
strings.EqualFold(trimmed[len(trimmed)-len(suffix):], suffix) {
+               return strings.TrimSpace(trimmed[:len(trimmed)-len(suffix)]), 
true
+       }
+
+       return trimmed, false
+}
+
+func compositeTypeParts(input string) (string, string, bool, error) {
+       trimmed := strings.TrimSpace(input)
+       opening := strings.IndexByte(trimmed, '<')
+       leading := trimmed
+       if opening >= 0 {
+               leading = strings.TrimSpace(trimmed[:opening])
+       }
+       root := strings.ToUpper(leading)
+       isCompositeRoot := root == "ARRAY" || root == "MAP" || root == 
"MULTISET" || root == "ROW" || root == "VECTOR"
+       if !isCompositeRoot {
+               return "", "", false, nil
+       }
+       if opening < 0 {
+               return "", "", false, fmt.Errorf("invalid %s type %q: missing 
angle brackets", root, input)
+       }
+
+       closing, err := matchingAngleBracket(trimmed, opening)
+       if err != nil {
+               return "", "", false, fmt.Errorf("invalid %s type %q: %w", 
root, input, err)
+       }
+       if strings.TrimSpace(trimmed[closing+1:]) != "" {
+               return "", "", false, fmt.Errorf("invalid %s type %q: 
unexpected trailing content", root, input)
+       }
+
+       return root, trimmed[opening+1 : closing], true, nil
+}
+
+func matchingAngleBracket(input string, opening int) (int, error) {
+       depth := 0
+       parenDepth, squareDepth, braceDepth := 0, 0, 0
+       defaultAtDepth := make(map[int]bool)
+       inString := false
+       inIdentifier := false
+       for index := opening; index < len(input); index++ {
+               if !inString && !inIdentifier && parenDepth == 0 && squareDepth 
== 0 && braceDepth == 0 && hasKeywordAt(input, index, "DEFAULT") {
+                       defaultAtDepth[depth] = true
+               }
+               switch input[index] {
+               case '\'':
+                       if inIdentifier {
+                               continue
+                       }
+                       if inString && index+1 < len(input) && input[index+1] 
== '\'' {
+                               index++
+
+                               continue
+                       }
+                       inString = !inString
+               case '`':
+                       if inString {
+                               continue
+                       }
+                       if inIdentifier && index+1 < len(input) && 
input[index+1] == '`' {
+                               index++
+
+                               continue
+                       }
+                       inIdentifier = !inIdentifier
+               case '<':
+                       if !inString && !inIdentifier && !defaultAtDepth[depth] 
&& parenDepth == 0 && squareDepth == 0 && braceDepth == 0 && 
isCompositeAngleOpening(input, index) {
+                               depth++
+                       }
+               case '>':
+                       if !inString && !inIdentifier && parenDepth == 0 && 
squareDepth == 0 && braceDepth == 0 && isStructuralAngleClosing(input, index) {
+                               delete(defaultAtDepth, depth)
+                               depth--
+                               if depth == 0 {
+                                       return index, nil
+                               }
+                               if depth < 0 {
+                                       return -1, errors.New("unexpected 
closing angle bracket")
+                               }
+                       }
+               case '(':
+                       if !inString && !inIdentifier {
+                               parenDepth++
+                       }
+               case ')':
+                       if !inString && !inIdentifier {
+                               parenDepth--
+                       }
+               case '[':
+                       if !inString && !inIdentifier {
+                               squareDepth++
+                       }
+               case ']':
+                       if !inString && !inIdentifier {
+                               squareDepth--
+                       }
+               case '{':
+                       if !inString && !inIdentifier {
+                               braceDepth++
+                       }
+               case '}':
+                       if !inString && !inIdentifier {
+                               braceDepth--
+                       }
+               case ',':
+                       if !inString && !inIdentifier && parenDepth == 0 && 
squareDepth == 0 && braceDepth == 0 {
+                               delete(defaultAtDepth, depth)
+                       }
+               }
+               if parenDepth < 0 || squareDepth < 0 || braceDepth < 0 {
+                       return -1, errors.New("unbalanced delimiters")
+               }
+       }
+       if inString || inIdentifier || parenDepth != 0 || squareDepth != 0 || 
braceDepth != 0 {
+               return -1, errors.New("unbalanced delimiters or quotes")
+       }
+
+       return -1, errors.New("missing closing angle bracket")
+}
+
+func splitTopLevel(input string, separator byte) ([]string, error) {
+       parts := make([]string, 0, 2)
+       start := 0
+       angleDepth, parenDepth, squareDepth, braceDepth := 0, 0, 0, 0
+       defaultAtDepth := make(map[int]bool)
+       inString := false
+       inIdentifier := false
+       for index := 0; index < len(input); index++ {
+               character := input[index]
+               if !inString && !inIdentifier && parenDepth == 0 && squareDepth 
== 0 && braceDepth == 0 && hasKeywordAt(input, index, "DEFAULT") {
+                       defaultAtDepth[angleDepth] = true
+               }
+               switch character {
+               case '\'':
+                       if inIdentifier {
+                               continue
+                       }
+                       if inString && index+1 < len(input) && input[index+1] 
== '\'' {
+                               index++
+
+                               continue
+                       }
+                       inString = !inString
+               case '`':
+                       if inString {
+                               continue
+                       }
+                       if inIdentifier && index+1 < len(input) && 
input[index+1] == '`' {
+                               index++
+
+                               continue
+                       }
+                       inIdentifier = !inIdentifier
+               case '<':
+                       if !inString && !inIdentifier && 
!defaultAtDepth[angleDepth] && parenDepth == 0 && squareDepth == 0 && 
braceDepth == 0 && isCompositeAngleOpening(input, index) {
+                               angleDepth++
+                       }
+               case '>':
+                       if !inString && !inIdentifier && parenDepth == 0 && 
squareDepth == 0 && braceDepth == 0 && isStructuralAngleClosing(input, index) {
+                               delete(defaultAtDepth, angleDepth)
+                               angleDepth--
+                       }
+               case '(':
+                       if !inString && !inIdentifier {
+                               parenDepth++
+                       }
+               case ')':
+                       if !inString && !inIdentifier {
+                               parenDepth--
+                       }
+               case '[':
+                       if !inString && !inIdentifier {
+                               squareDepth++
+                       }
+               case ']':
+                       if !inString && !inIdentifier {
+                               squareDepth--
+                       }
+               case '{':
+                       if !inString && !inIdentifier {
+                               braceDepth++
+                       }
+               case '}':
+                       if !inString && !inIdentifier {
+                               braceDepth--
+                       }
+               }
+               if angleDepth < 0 || parenDepth < 0 || squareDepth < 0 || 
braceDepth < 0 {
+                       return nil, errors.New("unbalanced delimiters")
+               }
+               if character == separator && !inString && !inIdentifier && 
angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0 {
+                       delete(defaultAtDepth, angleDepth)
+                       parts = append(parts, 
strings.TrimSpace(input[start:index]))
+                       start = index + 1
+               }
+       }
+       if inString || inIdentifier || angleDepth != 0 || parenDepth != 0 || 
squareDepth != 0 || braceDepth != 0 {
+               return nil, errors.New("unbalanced delimiters or quotes")
+       }
+       parts = append(parts, strings.TrimSpace(input[start:]))
+
+       return parts, nil
+}
+
+func parseRowField(input string) (string, string, *string, *string, error) {
+       trimmed := strings.TrimSpace(input)
+       if trimmed == "" {
+               return "", "", nil, nil, errors.New("empty field declaration")
+       }
+
+       name, remainder, err := consumeRowFieldName(trimmed)
+       if err != nil {
+               return "", "", nil, nil, err
+       }
+       if strings.TrimSpace(remainder) == "" {
+               return "", "", nil, nil, fmt.Errorf("field %q is missing a 
type", name)
+       }
+
+       commentPosition := findTopLevelKeyword(remainder, "COMMENT")
+       defaultPosition := findTopLevelKeyword(remainder, "DEFAULT")
+       typeEnd := len(remainder)
+       if commentPosition >= 0 && commentPosition < typeEnd {
+               typeEnd = commentPosition
+       }
+       if defaultPosition >= 0 && defaultPosition < typeEnd {
+               typeEnd = defaultPosition
+       }
+       fieldType := strings.TrimSpace(remainder[:typeEnd])
+       if fieldType == "" {
+               return "", "", nil, nil, fmt.Errorf("field %q is missing a 
type", name)
+       }
+
+       var description, defaultValue *string
+       if commentPosition >= 0 {
+               if defaultPosition >= 0 && defaultPosition < commentPosition {
+                       return "", "", nil, nil, fmt.Errorf("field %q COMMENT 
must precede DEFAULT", name)
+               }
+               commentEnd := len(remainder)
+               if defaultPosition >= 0 {
+                       commentEnd = defaultPosition
+               }
+               commentText := 
strings.TrimSpace(remainder[commentPosition+len("COMMENT") : commentEnd])
+               decoded, err := decodeSQLString(commentText)
+               if err != nil {
+                       return "", "", nil, nil, fmt.Errorf("field %q has 
invalid COMMENT: %w", name, err)
+               }
+               description = &decoded
+       }
+       if defaultPosition >= 0 {
+               value := 
strings.TrimSpace(remainder[defaultPosition+len("DEFAULT"):])
+               if value == "" {
+                       return "", "", nil, nil, fmt.Errorf("field %q has an 
empty DEFAULT", name)
+               }
+               defaultValue = &value
+       }
+
+       return name, fieldType, description, defaultValue, nil
+}
+
+func consumeRowFieldName(input string) (string, string, error) {
+       if input[0] != '`' {
+               index := strings.IndexFunc(input, unicode.IsSpace)
+               if index <= 0 {
+                       return "", "", errors.New("expected a field name 
followed by a type")
+               }
+
+               return input[:index], input[index:], nil
+       }
+
+       var name strings.Builder
+       for index := 1; index < len(input); index++ {
+               if input[index] != '`' {
+                       name.WriteByte(input[index])
+
+                       continue
+               }
+               if index+1 < len(input) && input[index+1] == '`' {
+                       name.WriteByte('`')
+                       index++
+
+                       continue
+               }
+               if index+1 < len(input) && 
!unicode.IsSpace(rune(input[index+1])) {
+                       return "", "", errors.New("expected whitespace after 
quoted field name")
+               }
+
+               return name.String(), input[index+1:], nil
+       }
+
+       return "", "", errors.New("unterminated quoted field name")
+}
+
+func findTopLevelKeyword(input, keyword string) int {
+       angleDepth, parenDepth, squareDepth, braceDepth := 0, 0, 0, 0
+       inString := false
+       inIdentifier := false
+       for index := 0; index < len(input); index++ {
+               switch input[index] {
+               case '\'':
+                       if inIdentifier {
+                               continue
+                       }
+                       if inString && index+1 < len(input) && input[index+1] 
== '\'' {
+                               index++
+
+                               continue
+                       }
+                       inString = !inString
+               case '`':
+                       if inString {
+                               continue
+                       }
+                       if inIdentifier && index+1 < len(input) && 
input[index+1] == '`' {
+                               index++
+
+                               continue
+                       }
+                       inIdentifier = !inIdentifier
+               case '<':
+                       if !inString && !inIdentifier && parenDepth == 0 && 
squareDepth == 0 && braceDepth == 0 && isCompositeAngleOpening(input, index) {
+                               angleDepth++
+                       }
+               case '>':
+                       if !inString && !inIdentifier && parenDepth == 0 && 
squareDepth == 0 && braceDepth == 0 && isStructuralAngleClosing(input, index) {
+                               angleDepth--
+                       }
+               case '(':
+                       if !inString && !inIdentifier {
+                               parenDepth++
+                       }
+               case ')':
+                       if !inString && !inIdentifier {
+                               parenDepth--
+                       }
+               case '[':
+                       if !inString && !inIdentifier {
+                               squareDepth++
+                       }
+               case ']':
+                       if !inString && !inIdentifier {
+                               squareDepth--
+                       }
+               case '{':
+                       if !inString && !inIdentifier {
+                               braceDepth++
+                       }
+               case '}':
+                       if !inString && !inIdentifier {
+                               braceDepth--
+                       }
+               default:
+                       if !inString && !inIdentifier && angleDepth == 0 && 
parenDepth == 0 && squareDepth == 0 && braceDepth == 0 && hasKeywordAt(input, 
index, keyword) {
+                               return index
+                       }
+               }
+       }
+
+       return -1
+}
+
+func isCompositeAngleOpening(input string, index int) bool {
+       if index < 0 || index >= len(input) || input[index] != '<' {
+               return false
+       }
+       end := index
+       for end > 0 && unicode.IsSpace(rune(input[end-1])) {
+               end--
+       }
+       start := end
+       for start > 0 {
+               character := input[start-1]
+               if character != '_' && (character < 'A' || character > 'Z') && 
(character < 'a' || character > 'z') {
+                       break
+               }
+               start--
+       }
+
+       switch strings.ToUpper(input[start:end]) {
+       case "ARRAY", "MAP", "MULTISET", "ROW", "VECTOR":
+               return true
+       default:
+               return false
+       }
+}
+
+func isStructuralAngleClosing(input string, index int) bool {
+       if index < 0 || index >= len(input) || input[index] != '>' {
+               return false
+       }
+       next := index + 1
+       for next < len(input) && unicode.IsSpace(rune(input[next])) {
+               next++
+       }
+       if next == len(input) {
+               return true
+       }
+       switch input[next] {
+       case ',', '>', ')', ']', '}':
+               return true
+       }
+
+       return hasKeywordAt(input, next, "NOT") || hasKeywordAt(input, next, 
"COMMENT") || hasKeywordAt(input, next, "DEFAULT")
+}
+
+func hasKeywordAt(input string, index int, keyword string) bool {
+       if index+len(keyword) > len(input) || 
!strings.EqualFold(input[index:index+len(keyword)], keyword) {
+               return false
+       }
+       if index > 0 && !unicode.IsSpace(rune(input[index-1])) {
+               return false
+       }
+       end := index + len(keyword)
+
+       return end == len(input) || unicode.IsSpace(rune(input[end]))
+}
+
+func decodeSQLString(input string) (string, error) {
+       trimmed := strings.TrimSpace(input)
+       if len(trimmed) < 2 || trimmed[0] != '\'' || trimmed[len(trimmed)-1] != 
'\'' {
+               return "", errors.New("expected a single-quoted string")
+       }
+       contents := trimmed[1 : len(trimmed)-1]
+       for index := 0; index < len(contents); index++ {
+               if contents[index] != '\'' {
+                       continue
+               }
+               if index+1 >= len(contents) || contents[index+1] != '\'' {
+                       return "", errors.New("unescaped single quote")
+               }
+               index++
+       }
+
+       return strings.ReplaceAll(contents, "''", "'"), nil
+}
diff --git a/internal/client/dlf_auth.go b/internal/client/dlf_auth.go
index d9f6391..dc44bb5 100644
--- a/internal/client/dlf_auth.go
+++ b/internal/client/dlf_auth.go
@@ -145,6 +145,7 @@ func newDLFAuthenticator(endpoint *url.URL, config 
DLFConfig, defaultHTTPClient
        if httpClient == nil {
                httpClient = defaultHTTPClient
        }
+       httpClient = noRedirectHTTPClient(httpClient)
        provider, err := newDLFCredentialProvider(config, httpClient)
        if err != nil {
                return nil, err
@@ -343,21 +344,15 @@ func (l *fileDLFTokenLoader) Load(ctx context.Context) 
(dlfCredentials, error) {
        }
        var lastErr error
        for attempt := 1; attempt <= attempts; attempt++ {
-               contents, err := os.ReadFile(l.path)
+               contents, err := readDLFTokenFile(l.path)
                if err == nil {
-                       if len(contents) > 1<<20 {
-                               err = errors.New("DLF token file is larger than 
1 MiB")
-                       } else {
-                               var credentials dlfCredentials
-                               if decodeErr := json.Unmarshal(contents, 
&credentials); decodeErr == nil {
-                                       if validationErr := 
credentials.validate(); validationErr == nil {
-                                               return credentials, nil
-                                       }
+                       var credentials dlfCredentials
+                       if decodeErr := json.Unmarshal(contents, &credentials); 
decodeErr == nil {
+                               if validationErr := credentials.validate(); 
validationErr == nil {
+                                       return credentials, nil
                                }
-                               err = errors.New("failed to parse DLF token 
file")
                        }
-               } else {
-                       err = errors.New("failed to read DLF token file")
+                       err = errors.New("failed to parse DLF token file")
                }
                lastErr = err
                if attempt == attempts {
@@ -377,6 +372,24 @@ func (l *fileDLFTokenLoader) Load(ctx context.Context) 
(dlfCredentials, error) {
        return dlfCredentials{}, lastErr
 }
 
+func readDLFTokenFile(path string) ([]byte, error) {
+       file, err := os.Open(path)
+       if err != nil {
+               return nil, errors.New("failed to read DLF token file")
+       }
+       defer file.Close()
+
+       contents, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
+       if err != nil {
+               return nil, errors.New("failed to read DLF token file")
+       }
+       if len(contents) > 1<<20 {
+               return nil, errors.New("DLF token file is larger than 1 MiB")
+       }
+
+       return contents, nil
+}
+
 type ecsDLFTokenLoader struct {
        metadataURL string
        roleName    string
diff --git a/internal/client/dlf_auth_test.go b/internal/client/dlf_auth_test.go
index d7678e2..d1147e2 100644
--- a/internal/client/dlf_auth_test.go
+++ b/internal/client/dlf_auth_test.go
@@ -194,6 +194,15 @@ func 
TestFileDLFTokenLoaderLoadsSTSAndDoesNotLeakMalformedContent(t *testing.T)
        assert.NotContains(t, err.Error(), "must-not-appear-in-error")
 }
 
+func TestFileDLFTokenLoaderRejectsOversizedFile(t *testing.T) {
+       path := filepath.Join(t.TempDir(), "token.json")
+       require.NoError(t, os.WriteFile(path, []byte(strings.Repeat("x", 
(1<<20)+1)), 0o600))
+
+       loader := &fileDLFTokenLoader{path: path, maxAttempts: 1}
+       _, err := loader.Load(context.Background())
+       require.EqualError(t, err, "DLF token file is larger than 1 MiB")
+}
+
 func TestECSDLFTokenLoaderDiscoversAndCachesRole(t *testing.T) {
        var roleRequests atomic.Int32
        var credentialRequests atomic.Int32
diff --git a/internal/client/models.go b/internal/client/models.go
index cfbc691..0e72876 100644
--- a/internal/client/models.go
+++ b/internal/client/models.go
@@ -101,12 +101,18 @@ type alterTableRequest struct {
 }
 
 // DataType is Paimon's language-neutral REST representation of a data type.
-// It is encoded as Paimon's SQL type string on writes. Reads also accept the
-// structured JSON form used for ARRAY, MAP, MULTISET, ROW and VECTOR values.
+// Atomic types use SQL strings. ARRAY, MAP, MULTISET, ROW and VECTOR use the
+// structured JSON form required by Paimon's REST type parser.
 type DataType string
 
 func (t DataType) MarshalJSON() ([]byte, error) {
-       return json.Marshal(string(t))
+       nextFieldID := -1
+       value, err := encodeDataType(string(t), &nextFieldID)
+       if err != nil {
+               return nil, err
+       }
+
+       return json.Marshal(value)
 }
 
 func (t *DataType) UnmarshalJSON(data []byte) error {
@@ -155,7 +161,7 @@ func (t *DataType) UnmarshalJSON(data []byte) error {
                fields := make([]string, 0, len(structured.Fields))
                for _, field := range structured.Fields {
                        part := quoteIdentifier(field.Name) + " " + 
string(field.Type)
-                       if field.Description != nil {
+                       if field.Description != nil && *field.Description != "" 
{
                                part += " COMMENT '" + 
strings.ReplaceAll(*field.Description, "'", "''") + "'"
                        }
                        if field.DefaultValue != nil {
diff --git a/internal/provider/provider_test.go 
b/internal/provider/provider_test.go
index 287ca87..d925e0b 100644
--- a/internal/provider/provider_test.go
+++ b/internal/provider/provider_test.go
@@ -19,13 +19,21 @@ package provider
 
 import (
        "context"
+       "encoding/json"
+       "net/http"
+       "net/http/httptest"
        "testing"
 
        "github.com/apache/terraform-provider-paimon/internal/client"
+       "github.com/hashicorp/terraform-plugin-framework/attr"
        "github.com/hashicorp/terraform-plugin-framework/datasource"
+       "github.com/hashicorp/terraform-plugin-framework/diag"
+       "github.com/hashicorp/terraform-plugin-framework/path"
        frameworkprovider 
"github.com/hashicorp/terraform-plugin-framework/provider"
        providerschema 
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
        "github.com/hashicorp/terraform-plugin-framework/resource"
+       "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+       "github.com/hashicorp/terraform-plugin-framework/tfsdk"
        "github.com/hashicorp/terraform-plugin-framework/types"
        "github.com/stretchr/testify/assert"
        "github.com/stretchr/testify/require"
@@ -123,3 +131,328 @@ func 
TestSchemaFromResourceModelNormalizesPrimaryKeyNullability(t *testing.T) {
        require.Len(t, tableSchema.Fields, 1)
        assert.Equal(t, client.DataType("BIGINT NOT NULL"), 
tableSchema.Fields[0].Type)
 }
+
+func TestSchemaFromResourceModelAllocatesUnusedFieldIDs(t *testing.T) {
+       ctx := context.Background()
+       fields, diagnostics := types.ListValueFrom(ctx, 
types.ObjectType{AttrTypes: tableFieldAttrTypes()}, []tableFieldModel{
+               tableFieldForTest("first", types.Int64Value(1)),
+               tableFieldForTest("second", types.Int64Unknown()),
+               tableFieldForTest("third", types.Int64Value(3)),
+               tableFieldForTest("fourth", types.Int64Null()),
+               tableFieldForTest("max", types.Int64Value(maxPaimonFieldID)),
+       })
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+       model := tableResourceModel{
+               Fields:        fields,
+               PartitionKeys: types.ListNull(types.StringType),
+               PrimaryKeys:   types.ListNull(types.StringType),
+               Options:       types.MapNull(types.StringType),
+               Comment:       types.StringNull(),
+       }
+
+       tableSchema := schemaFromResourceModel(ctx, &model, &diagnostics)
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+       require.Len(t, tableSchema.Fields, 5)
+       assert.Equal(t, []int{1, 0, 3, 2, maxPaimonFieldID}, []int{
+               tableSchema.Fields[0].ID,
+               tableSchema.Fields[1].ID,
+               tableSchema.Fields[2].ID,
+               tableSchema.Fields[3].ID,
+               tableSchema.Fields[4].ID,
+       })
+}
+
+func TestSchemaFromResourceModelRejectsInvalidFieldIDs(t *testing.T) {
+       ctx := context.Background()
+       fields, diagnostics := types.ListValueFrom(ctx, 
types.ObjectType{AttrTypes: tableFieldAttrTypes()}, []tableFieldModel{
+               tableFieldForTest("first", types.Int64Value(2)),
+               tableFieldForTest("duplicate", types.Int64Value(2)),
+               tableFieldForTest("negative", types.Int64Value(-1)),
+               tableFieldForTest("reserved", 
types.Int64Value(maxPaimonFieldID+1)),
+       })
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+       model := tableResourceModel{
+               Fields:        fields,
+               PartitionKeys: types.ListNull(types.StringType),
+               PrimaryKeys:   types.ListNull(types.StringType),
+               Options:       types.MapNull(types.StringType),
+               Comment:       types.StringNull(),
+       }
+
+       _ = schemaFromResourceModel(ctx, &model, &diagnostics)
+       require.True(t, diagnostics.HasError())
+       require.Len(t, diagnostics.Errors(), 3)
+       assert.Contains(t, diagnostics.Errors()[0].Summary(), "Duplicate Paimon 
field ID")
+       assert.Contains(t, diagnostics.Errors()[1].Summary(), "Invalid Paimon 
field ID")
+       assert.Contains(t, diagnostics.Errors()[2].Summary(), "Invalid Paimon 
field ID")
+}
+
+func TestReservedTableOptionsValidator(t *testing.T) {
+       ctx := context.Background()
+       options, diagnostics := types.MapValueFrom(ctx, types.StringType, 
map[string]string{
+               "bucket":      "4",
+               "partition":   "dt",
+               "primary-key": "id",
+       })
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+
+       var response validator.MapResponse
+       reservedTableOptionsValidator{}.ValidateMap(ctx, validator.MapRequest{
+               Path:        path.Root("options"),
+               ConfigValue: options,
+       }, &response)
+       require.True(t, response.Diagnostics.HasError())
+       assert.Contains(t, response.Diagnostics.Errors()[0].Detail(), 
"partition, primary-key")
+}
+
+func TestImmutableTableOptionsChanged(t *testing.T) {
+       mapValue := func(values map[string]attr.Value) types.Map {
+               return types.MapValueMust(types.StringType, values)
+       }
+
+       assert.False(t, immutableTableOptionsChanged(
+               mapValue(map[string]attr.Value{"bucket": 
types.StringValue("2")}),
+               mapValue(map[string]attr.Value{"bucket": 
types.StringValue("4")}),
+       ))
+       assert.True(t, immutableTableOptionsChanged(
+               mapValue(map[string]attr.Value{"merge-engine": 
types.StringValue("deduplicate")}),
+               mapValue(map[string]attr.Value{"merge-engine": 
types.StringValue("partial-update")}),
+       ))
+       assert.True(t, immutableTableOptionsChanged(
+               mapValue(map[string]attr.Value{"primary-key.nullable": 
types.StringValue("true")}),
+               mapValue(map[string]attr.Value{}),
+       ))
+       assert.False(t, immutableTableOptionsChanged(
+               types.MapNull(types.StringType),
+               mapValue(map[string]attr.Value{"type": 
types.StringValue("table")}),
+       ))
+       assert.False(t, immutableTableOptionsChanged(
+               mapValue(map[string]attr.Value{"type": 
types.StringValue("table")}),
+               mapValue(map[string]attr.Value{"type": 
types.StringValue("TABLE")}),
+       ))
+       assert.False(t, immutableTableOptionsChanged(
+               mapValue(map[string]attr.Value{"type": 
types.StringValue("table")}),
+               mapValue(map[string]attr.Value{}),
+       ))
+       assert.False(t, immutableTableOptionsChanged(
+               types.MapUnknown(types.StringType),
+               mapValue(map[string]attr.Value{"merge-engine": 
types.StringValue("partial-update")}),
+       ))
+       assert.False(t, immutableTableOptionsChanged(
+               mapValue(map[string]attr.Value{"merge-engine": 
types.StringValue("deduplicate")}),
+               mapValue(map[string]attr.Value{"merge-engine": 
types.StringUnknown()}),
+       ))
+       assert.True(t, immutableTableOptionsChanged(
+               mapValue(map[string]attr.Value{
+                       "bucket-key":   types.StringValue("id"),
+                       "merge-engine": types.StringValue("deduplicate"),
+               }),
+               mapValue(map[string]attr.Value{
+                       "bucket-key":   types.StringUnknown(),
+                       "merge-engine": types.StringValue("partial-update"),
+               }),
+       ))
+}
+
+func TestTableTypeSemanticNoOpPreservesConfiguredValue(t *testing.T) {
+       removals, updates := diffTableOptions(
+               map[string]string{},
+               map[string]string{"type": "table"},
+       )
+       assert.Empty(t, removals)
+       assert.Empty(t, updates)
+
+       removals, updates = diffTableOptions(
+               map[string]string{"type": "table"},
+               map[string]string{"type": "TABLE"},
+       )
+       assert.Empty(t, removals)
+       assert.Empty(t, updates)
+
+       removals, updates = diffTableOptions(
+               map[string]string{"type": "table"},
+               map[string]string{},
+       )
+       assert.Empty(t, removals)
+       assert.Empty(t, updates)
+
+       ctx := context.Background()
+       managed := types.MapValueMust(types.StringType, map[string]attr.Value{
+               "type": types.StringValue("TABLE"),
+       })
+       var diagnostics diag.Diagnostics
+       synced := syncManagedTableOptions(ctx, managed, map[string]string{}, 
&diagnostics)
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+       assert.Equal(t, map[string]string{"type": "TABLE"}, mapFromValue(ctx, 
synced, &diagnostics))
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+
+       synced = syncManagedTableOptions(ctx, managed, 
map[string]string{"type": "table"}, &diagnostics)
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+       assert.Equal(t, map[string]string{"type": "TABLE"}, mapFromValue(ctx, 
synced, &diagnostics))
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+}
+
+func TestTableResourceLifecycle(t *testing.T) {
+       ctx := context.Background()
+       remote := client.Table{
+               ID:       "table-id",
+               Database: "analytics",
+               Name:     "events",
+               SchemaID: 1,
+               Schema: client.Schema{
+                       Fields: []client.Field{
+                               {ID: 0, Name: "id", Type: 
client.DataType("BIGINT NOT NULL")},
+                               {ID: 1, Name: "labels", Type: 
client.DataType("MAP<STRING, STRING>")},
+                               {ID: 2, Name: "payload", Type: 
client.DataType("ROW<`item` STRING>")},
+                       },
+                       PartitionKeys: []string{},
+                       PrimaryKeys:   []string{"id"},
+                       Options:       map[string]string{"bucket": "2", 
"server-only": "preserved"},
+               },
+       }
+       createCalls, readCalls, updateCalls, deleteCalls := 0, 0, 0, 0
+       server := httptest.NewServer(http.HandlerFunc(func(w 
http.ResponseWriter, request *http.Request) {
+               w.Header().Set("Content-Type", "application/json")
+               switch {
+               case request.Method == http.MethodGet && request.URL.Path == 
"/v1/config":
+                       require.NoError(t, 
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults: 
map[string]string{"prefix": "catalog"}}))
+               case request.Method == http.MethodPost && request.URL.Path == 
"/v1/catalog/databases/analytics/tables":
+                       createCalls++
+                       var body struct {
+                               Identifier client.Identifier `json:"identifier"`
+                               Schema     client.Schema     `json:"schema"`
+                       }
+                       require.NoError(t, 
json.NewDecoder(request.Body).Decode(&body))
+                       assert.Equal(t, "events", body.Identifier.Object)
+                       assert.Equal(t, map[string]string{"bucket": "2"}, 
body.Schema.Options)
+                       w.WriteHeader(http.StatusOK)
+               case request.Method == http.MethodGet && request.URL.Path == 
"/v1/catalog/databases/analytics/tables/events":
+                       readCalls++
+                       require.NoError(t, json.NewEncoder(w).Encode(remote))
+               case request.Method == http.MethodPost && request.URL.Path == 
"/v1/catalog/databases/analytics/tables/events":
+                       updateCalls++
+                       var body struct {
+                               Changes []client.SchemaChange `json:"changes"`
+                       }
+                       require.NoError(t, 
json.NewDecoder(request.Body).Decode(&body))
+                       assert.Equal(t, []client.SchemaChange{{"action": 
"setOption", "key": "bucket", "value": "4"}}, body.Changes)
+                       remote.Schema.Options["bucket"] = "4"
+                       remote.SchemaID++
+                       w.WriteHeader(http.StatusOK)
+               case request.Method == http.MethodDelete && request.URL.Path == 
"/v1/catalog/databases/analytics/tables/events":
+                       deleteCalls++
+                       w.WriteHeader(http.StatusNoContent)
+               default:
+                       http.NotFound(w, request)
+               }
+       }))
+       defer server.Close()
+
+       api, err := client.New(client.Config{URI: server.URL})
+       require.NoError(t, err)
+       table := &tableResource{client: api}
+       var schemaResponse resource.SchemaResponse
+       table.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+       require.False(t, schemaResponse.Diagnostics.HasError(), 
schemaResponse.Diagnostics.Errors())
+
+       fields, diagnostics := types.ListValueFrom(ctx, 
types.ObjectType{AttrTypes: tableFieldAttrTypes()}, []tableFieldModel{
+               {
+                       ID:           types.Int64Unknown(),
+                       Name:         types.StringValue("id"),
+                       Type:         types.StringValue("BIGINT"),
+                       Nullable:     types.BoolValue(false),
+                       Description:  types.StringNull(),
+                       DefaultValue: types.StringNull(),
+               },
+               {
+                       ID:           types.Int64Unknown(),
+                       Name:         types.StringValue("labels"),
+                       Type:         types.StringValue("MAP<STRING,STRING>"),
+                       Nullable:     types.BoolValue(true),
+                       Description:  types.StringNull(),
+                       DefaultValue: types.StringNull(),
+               },
+               {
+                       ID:           types.Int64Unknown(),
+                       Name:         types.StringValue("payload"),
+                       Type:         types.StringValue("ROW<`item` STRING>"),
+                       Nullable:     types.BoolValue(true),
+                       Description:  types.StringNull(),
+                       DefaultValue: types.StringNull(),
+               },
+       })
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+       primaryKeys, diagnostics := types.ListValueFrom(ctx, types.StringType, 
[]string{"id"})
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+       options, diagnostics := types.MapValueFrom(ctx, types.StringType, 
map[string]string{"bucket": "2"})
+       require.False(t, diagnostics.HasError(), diagnostics.Errors())
+       planModel := tableResourceModel{
+               ID:            types.StringUnknown(),
+               CatalogID:     types.StringUnknown(),
+               Database:      types.StringValue("analytics"),
+               Name:          types.StringValue("events"),
+               Fields:        fields,
+               PartitionKeys: types.ListValueMust(types.StringType, 
[]attr.Value{}),
+               PrimaryKeys:   primaryKeys,
+               Options:       options,
+               ServerOptions: types.MapUnknown(types.StringType),
+               Comment:       types.StringNull(),
+               SchemaID:      types.Int64Unknown(),
+               Path:          types.StringUnknown(),
+               IsExternal:    types.BoolUnknown(),
+               Owner:         types.StringUnknown(),
+               CreatedAt:     types.Int64Unknown(),
+               CreatedBy:     types.StringUnknown(),
+               UpdatedAt:     types.Int64Unknown(),
+               UpdatedBy:     types.StringUnknown(),
+       }
+       plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+       require.False(t, plan.Set(ctx, &planModel).HasError())
+       createResponse := resource.CreateResponse{State: tfsdk.State{Schema: 
schemaResponse.Schema}}
+       table.Create(ctx, resource.CreateRequest{Plan: plan}, &createResponse)
+       require.False(t, createResponse.Diagnostics.HasError(), 
createResponse.Diagnostics.Errors())
+       var createdModel tableResourceModel
+       require.False(t, createResponse.State.Get(ctx, 
&createdModel).HasError())
+       var createdFields []tableFieldModel
+       require.False(t, createdModel.Fields.ElementsAs(ctx, &createdFields, 
false).HasError())
+       require.Len(t, createdFields, 3)
+       assert.Equal(t, "MAP<STRING,STRING>", 
createdFields[1].Type.ValueString())
+       assert.Equal(t, "ROW<`item` STRING>", 
createdFields[2].Type.ValueString())
+
+       readResponse := resource.ReadResponse{State: createResponse.State}
+       table.Read(ctx, resource.ReadRequest{State: createResponse.State}, 
&readResponse)
+       require.False(t, readResponse.Diagnostics.HasError(), 
readResponse.Diagnostics.Errors())
+
+       var updateModel tableResourceModel
+       require.False(t, readResponse.State.Get(ctx, &updateModel).HasError())
+       var refreshedFields []tableFieldModel
+       require.False(t, updateModel.Fields.ElementsAs(ctx, &refreshedFields, 
false).HasError())
+       require.Len(t, refreshedFields, 3)
+       assert.Equal(t, "MAP<STRING,STRING>", 
refreshedFields[1].Type.ValueString())
+       assert.Equal(t, "ROW<`item` STRING>", 
refreshedFields[2].Type.ValueString())
+       updateModel.Options = types.MapValueMust(types.StringType, 
map[string]attr.Value{"bucket": types.StringValue("4")})
+       updatePlan := tfsdk.Plan{Schema: schemaResponse.Schema}
+       require.False(t, updatePlan.Set(ctx, &updateModel).HasError())
+       updateResponse := resource.UpdateResponse{State: tfsdk.State{Schema: 
schemaResponse.Schema}}
+       table.Update(ctx, resource.UpdateRequest{State: readResponse.State, 
Plan: updatePlan}, &updateResponse)
+       require.False(t, updateResponse.Diagnostics.HasError(), 
updateResponse.Diagnostics.Errors())
+
+       deleteResponse := resource.DeleteResponse{State: updateResponse.State}
+       table.Delete(ctx, resource.DeleteRequest{State: updateResponse.State}, 
&deleteResponse)
+       require.False(t, deleteResponse.Diagnostics.HasError(), 
deleteResponse.Diagnostics.Errors())
+       assert.Equal(t, 1, createCalls)
+       assert.Equal(t, 3, readCalls)
+       assert.Equal(t, 1, updateCalls)
+       assert.Equal(t, 1, deleteCalls)
+}
+
+func tableFieldForTest(name string, id types.Int64) tableFieldModel {
+       return tableFieldModel{
+               ID:           id,
+               Name:         types.StringValue(name),
+               Type:         types.StringValue("STRING"),
+               Nullable:     types.BoolValue(true),
+               Description:  types.StringNull(),
+               DefaultValue: types.StringNull(),
+       }
+}
diff --git a/internal/provider/resource_table.go 
b/internal/provider/resource_table.go
index 9ad97e6..9714f3d 100644
--- a/internal/provider/resource_table.go
+++ b/internal/provider/resource_table.go
@@ -137,7 +137,7 @@ func (r *tableResource) Update(ctx context.Context, req 
resource.UpdateRequest,
        if resp.Diagnostics.HasError() {
                return
        }
-       removals, updates := diffOptions(before, after)
+       removals, updates := diffTableOptions(before, after)
        sort.Strings(removals)
        updateKeys := make([]string, 0, len(updates))
        for key := range updates {
@@ -216,10 +216,10 @@ func setTableResourceModel(ctx context.Context, model 
*tableResourceModel, table
        model.CatalogID = types.StringValue(table.ID)
        model.Database = types.StringValue(database)
        model.Name = types.StringValue(name)
-       model.Fields = fieldsValueFromRemote(ctx, table.Schema.Fields, diags)
+       model.Fields = resourceFieldsValueFromRemote(ctx, model.Fields, 
table.Schema.Fields, diags)
        model.PartitionKeys = stringListValue(ctx, table.Schema.PartitionKeys, 
diags)
        model.PrimaryKeys = stringListValue(ctx, table.Schema.PrimaryKeys, 
diags)
-       model.Options = syncManagedOptions(ctx, model.Options, 
table.Schema.Options, diags)
+       model.Options = syncManagedTableOptions(ctx, model.Options, 
table.Schema.Options, diags)
        model.ServerOptions = stringMapValue(ctx, table.Schema.Options, diags)
        model.Comment = stringValueFromPointer(table.Schema.Comment)
        model.SchemaID = types.Int64Value(table.SchemaID)
diff --git a/internal/provider/table_options.go 
b/internal/provider/table_options.go
new file mode 100644
index 0000000..d03f485
--- /dev/null
+++ b/internal/provider/table_options.go
@@ -0,0 +1,184 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package provider
+
+import (
+       "context"
+       "strings"
+
+       "github.com/hashicorp/terraform-plugin-framework/diag"
+       
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier"
+       
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+       "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+       "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+var immutableTableOptions = map[string]struct{}{
+       "aggregation.remove-record-on-delete":            {},
+       "blob-descriptor-field":                          {},
+       "blob-field":                                     {},
+       "blob-view-field":                                {},
+       "bucket-function.type":                           {},
+       "bucket-key":                                     {},
+       "data-evolution.enabled":                         {},
+       "data-file.path-directory":                       {},
+       "dynamic-bucket.initial-buckets":                 {},
+       "force-lookup":                                   {},
+       "index-file-in-data-file-dir":                    {},
+       "merge-engine":                                   {},
+       "partial-update.remove-record-on-delete":         {},
+       "partial-update.remove-record-on-sequence-group": {},
+       "partition":                                      {},
+       "pk-clustering-override":                         {},
+       "primary-key":                                    {},
+       "primary-key.nullable":                           {},
+       "row-tracking.enabled":                           {},
+       "rowkind.field":                                  {},
+       "sequence.snapshot-ordering":                     {},
+       "type":                                           {},
+}
+
+type reservedTableOptionsValidator struct{}
+
+func (reservedTableOptionsValidator) Description(context.Context) string {
+       return "must configure primary keys and partitions with their 
first-class attributes"
+}
+
+func (reservedTableOptionsValidator) MarkdownDescription(context.Context) 
string {
+       return "must configure primary keys and partitions with `primary_keys` 
and `partition_keys`"
+}
+
+func (reservedTableOptionsValidator) ValidateMap(_ context.Context, req 
validator.MapRequest, resp *validator.MapResponse) {
+       if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+               return
+       }
+       reserved := make([]string, 0, 2)
+       for _, key := range []string{"partition", "primary-key"} {
+               if _, exists := req.ConfigValue.Elements()[key]; exists {
+                       reserved = append(reserved, key)
+               }
+       }
+       if len(reserved) > 0 {
+               resp.Diagnostics.AddAttributeError(
+                       req.Path,
+                       "Reserved Paimon table option",
+                       "Do not configure "+strings.Join(reserved, ", ")+" in 
options. Use partition_keys and primary_keys instead.",
+               )
+       }
+}
+
+func immutableTableOptionsRequiresReplace(_ context.Context, req 
planmodifier.MapRequest, resp *mapplanmodifier.RequiresReplaceIfFuncResponse) {
+       resp.RequiresReplace = immutableTableOptionsChanged(req.StateValue, 
req.PlanValue)
+}
+
+func immutableTableOptionsChanged(before, after types.Map) bool {
+       for key := range immutableTableOptions {
+               beforeValue, beforeExists, beforeKnown := 
knownTableOption(before, key)
+               afterValue, afterExists, afterKnown := knownTableOption(after, 
key)
+               if !beforeKnown || !afterKnown {
+                       continue
+               }
+               if key == "type" {
+                       if !beforeExists {
+                               beforeValue = "table"
+                       }
+                       if !afterExists {
+                               afterValue = "table"
+                       }
+                       if !strings.EqualFold(beforeValue, afterValue) {
+                               return true
+                       }
+
+                       continue
+               }
+               if beforeExists != afterExists || beforeExists && beforeValue 
!= afterValue {
+                       return true
+               }
+       }
+
+       return false
+}
+
+func knownTableOption(options types.Map, key string) (string, bool, bool) {
+       if options.IsUnknown() {
+               return "", false, false
+       }
+       if options.IsNull() {
+               return "", false, true
+       }
+       element, exists := options.Elements()[key]
+       if !exists {
+               return "", false, true
+       }
+       value, ok := element.(types.String)
+       if !ok || value.IsNull() || value.IsUnknown() {
+               return "", true, false
+       }
+
+       return value.ValueString(), true, true
+}
+
+func diffTableOptions(before, after map[string]string) ([]string, 
map[string]string) {
+       removals, updates := diffOptions(before, after)
+       beforeType, beforeExists := before["type"]
+       if !beforeExists {
+               beforeType = "table"
+       }
+       afterType, afterExists := after["type"]
+       if !afterExists {
+               afterType = "table"
+       }
+       if strings.EqualFold(beforeType, afterType) {
+               delete(updates, "type")
+               for index, key := range removals {
+                       if key == "type" {
+                               removals = append(removals[:index], 
removals[index+1:]...)
+
+                               break
+                       }
+               }
+       }
+
+       return removals, updates
+}
+
+func syncManagedTableOptions(ctx context.Context, managed types.Map, remote 
map[string]string, diags *diag.Diagnostics) types.Map {
+       synced := syncManagedOptions(ctx, managed, remote, diags)
+       if managed.IsNull() || managed.IsUnknown() || diags.HasError() {
+               return synced
+       }
+       managedOptions := mapFromValue(ctx, managed, diags)
+       configuredType, managesType := managedOptions["type"]
+       if !managesType || diags.HasError() {
+               return synced
+       }
+       remoteType, hasRemoteType := remote["type"]
+       if !hasRemoteType {
+               remoteType = "table"
+       }
+       if !strings.EqualFold(configuredType, remoteType) {
+               return synced
+       }
+       syncedOptions := mapFromValue(ctx, synced, diags)
+       if diags.HasError() {
+               return synced
+       }
+       syncedOptions["type"] = configuredType
+
+       return stringMapValue(ctx, syncedOptions, diags)
+}
diff --git a/internal/provider/table_schema.go 
b/internal/provider/table_schema.go
index 23d2177..abcd91b 100644
--- a/internal/provider/table_schema.go
+++ b/internal/provider/table_schema.go
@@ -29,6 +29,7 @@ import (
        "github.com/hashicorp/terraform-plugin-framework/diag"
        rschema 
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
        
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
+       
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier"
        
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
        
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
        "github.com/hashicorp/terraform-plugin-framework/schema/validator"
@@ -44,6 +45,9 @@ type tableFieldModel struct {
        DefaultValue types.String `tfsdk:"default_value"`
 }
 
+// Paimon reserves IDs at and above SpecialFields.SYSTEM_FIELD_ID_START.
+const maxPaimonFieldID = (1 << 30) - 2
+
 func tableFieldAttrTypes() map[string]attr.Type {
        return map[string]attr.Type{
                "id":            types.Int64Type,
@@ -93,9 +97,11 @@ func tableResourceAttributes() map[string]rschema.Attribute {
                        PlanModifiers: 
[]planmodifier.List{listplanmodifier.RequiresReplace()},
                },
                "options": rschema.MapAttribute{
-                       Description: "Table options managed by Terraform. 
Options not declared here are preserved.",
-                       Optional:    true,
-                       ElementType: types.StringType,
+                       Description:   "Table options managed by Terraform. 
Options not declared here are preserved. Paimon options that are immutable 
after creation cause replacement when changed.",
+                       Optional:      true,
+                       ElementType:   types.StringType,
+                       Validators:    
[]validator.Map{reservedTableOptionsValidator{}},
+                       PlanModifiers: 
[]planmodifier.Map{mapplanmodifier.RequiresReplaceIf(immutableTableOptionsRequiresReplace,
 "replaces the table when an immutable Paimon option changes", "replaces the 
table when an immutable Paimon option changes")},
                },
                "server_options": rschema.MapAttribute{
                        Description: "All table options returned by the REST 
Catalog.",
@@ -117,7 +123,7 @@ func tableResourceAttributes() map[string]rschema.Attribute 
{
 func tableFieldResourceAttributes() map[string]rschema.Attribute {
        return map[string]rschema.Attribute{
                "id": rschema.Int64Attribute{
-                       Description: "Stable field ID. It is assigned by 
position when omitted.",
+                       Description: "Stable Paimon field ID between 0 and " + 
strconv.Itoa(maxPaimonFieldID) + ". The next available ID is assigned when 
omitted.",
                        Optional:    true,
                        Computed:    true,
                },
@@ -181,15 +187,11 @@ func schemaFromResourceModel(ctx context.Context, model 
*tableResourceModel, dia
        if diags.HasError() {
                return client.Schema{}
        }
-       if len(primaryKeys) > 0 {
-               if _, exists := options["primary-key"]; exists {
-                       diags.AddError("Conflicting primary key configuration", 
"Configure primary_keys or the primary-key table option, not both.")
-               }
+       if _, exists := options["primary-key"]; exists {
+               diags.AddError("Reserved table option", "Configure primary_keys 
instead of the primary-key table option.")
        }
-       if len(partitionKeys) > 0 {
-               if _, exists := options["partition"]; exists {
-                       diags.AddError("Conflicting partition configuration", 
"Configure partition_keys or the partition table option, not both.")
-               }
+       if _, exists := options["partition"]; exists {
+               diags.AddError("Reserved table option", "Configure 
partition_keys instead of the partition table option.")
        }
        primaryKeyNullable := false
        if configured, exists := options["primary-key.nullable"]; exists {
@@ -207,13 +209,10 @@ func schemaFromResourceModel(ctx context.Context, model 
*tableResourceModel, dia
 
        var fieldModels []tableFieldModel
        diags.Append(model.Fields.ElementsAs(ctx, &fieldModels, false)...)
+       fieldIDs := allocateFieldIDs(fieldModels, diags)
        fields := make([]client.Field, 0, len(fieldModels))
        fieldNames := make(map[string]struct{}, len(fieldModels))
        for index, field := range fieldModels {
-               fieldID := index
-               if !field.ID.IsNull() && !field.ID.IsUnknown() {
-                       fieldID = int(field.ID.ValueInt64())
-               }
                typeName := strings.TrimSpace(field.Type.ValueString())
                hasNotNullSuffix := 
strings.HasSuffix(strings.ToUpper(typeName), " NOT NULL")
                _, isPrimaryKey := primaryKeySet[field.Name.ValueString()]
@@ -242,7 +241,7 @@ func schemaFromResourceModel(ctx context.Context, model 
*tableResourceModel, dia
                        typeName += " NOT NULL"
                }
                fields = append(fields, client.Field{
-                       ID:           fieldID,
+                       ID:           fieldIDs[index],
                        Name:         field.Name.ValueString(),
                        Type:         client.DataType(typeName),
                        Description:  optionalStringPointer(field.Description),
@@ -265,6 +264,48 @@ func schemaFromResourceModel(ctx context.Context, model 
*tableResourceModel, dia
        }
 }
 
+func allocateFieldIDs(fields []tableFieldModel, diags *diag.Diagnostics) []int 
{
+       ids := make([]int, len(fields))
+       used := make(map[int]int, len(fields))
+       for index, field := range fields {
+               if field.ID.IsNull() || field.ID.IsUnknown() {
+                       continue
+               }
+               configured := field.ID.ValueInt64()
+               if configured < 0 || configured > maxPaimonFieldID {
+                       diags.AddError("Invalid Paimon field ID", "Field 
"+field.Name.ValueString()+" must have a field ID between 0 and 
"+strconv.Itoa(maxPaimonFieldID)+".")
+
+                       continue
+               }
+               id := int(configured)
+               if previous, duplicate := used[id]; duplicate {
+                       diags.AddError("Duplicate Paimon field ID", "Fields 
"+fields[previous].Name.ValueString()+" and "+field.Name.ValueString()+" use 
the same field ID: "+strconv.Itoa(id))
+
+                       continue
+               }
+               ids[index] = id
+               used[id] = index
+       }
+
+       next := 0
+       for index, field := range fields {
+               if !field.ID.IsNull() && !field.ID.IsUnknown() {
+                       continue
+               }
+               for {
+                       if _, exists := used[next]; !exists {
+                               break
+                       }
+                       next++
+               }
+               ids[index] = next
+               used[next] = index
+               next++
+       }
+
+       return ids
+}
+
 func validateKeyFields(attribute string, keys []string, fields 
map[string]struct{}, diags *diag.Diagnostics) {
        seen := make(map[string]struct{}, len(keys))
        for _, key := range keys {
@@ -279,6 +320,38 @@ func validateKeyFields(attribute string, keys []string, 
fields map[string]struct
 }
 
 func fieldsValueFromRemote(ctx context.Context, fields []client.Field, diags 
*diag.Diagnostics) types.List {
+       return fieldsValueFromModels(ctx, fieldModelsFromRemote(fields), diags)
+}
+
+func resourceFieldsValueFromRemote(ctx context.Context, managed types.List, 
fields []client.Field, diags *diag.Diagnostics) types.List {
+       models := fieldModelsFromRemote(fields)
+       if managed.IsNull() || managed.IsUnknown() {
+               return fieldsValueFromModels(ctx, models, diags)
+       }
+       var managedModels []tableFieldModel
+       newDiags := managed.ElementsAs(ctx, &managedModels, false)
+       if newDiags.HasError() || len(managedModels) != len(models) {
+               return fieldsValueFromModels(ctx, models, diags)
+       }
+       for index := range models {
+               if managedModels[index].Name.IsNull() || 
managedModels[index].Name.IsUnknown() || managedModels[index].Type.IsNull() || 
managedModels[index].Type.IsUnknown() {
+                       continue
+               }
+               if managedModels[index].Name.ValueString() != 
models[index].Name.ValueString() {
+                       continue
+               }
+               if client.EquivalentDataTypes(
+                       
client.DataType(managedModels[index].Type.ValueString()),
+                       client.DataType(models[index].Type.ValueString()),
+               ) {
+                       models[index].Type = managedModels[index].Type
+               }
+       }
+
+       return fieldsValueFromModels(ctx, models, diags)
+}
+
+func fieldModelsFromRemote(fields []client.Field) []tableFieldModel {
        models := make([]tableFieldModel, 0, len(fields))
        for _, field := range fields {
                typeName := strings.TrimSpace(string(field.Type))
@@ -296,6 +369,11 @@ func fieldsValueFromRemote(ctx context.Context, fields 
[]client.Field, diags *di
                        DefaultValue: 
stringValueFromPointer(field.DefaultValue),
                })
        }
+
+       return models
+}
+
+func fieldsValueFromModels(ctx context.Context, models []tableFieldModel, 
diags *diag.Diagnostics) types.List {
        value, newDiags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: 
tableFieldAttrTypes()}, models)
        diags.Append(newDiags...)
 

Reply via email to