You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@paimon.apache.org by lz...@apache.org on 2026/10/10 07:11:17 UTC

(terraform-provider-paimon) branch main updated: Plan no changes after importing row filters and column masks (#35)

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 53f428c  Plan no changes after importing row filters and column masks (#35)
53f428c is described below

commit 53f428c8aad43ded50283c3144ed8d2bc9c41775
Author: Jiajia Li <li...@outlook.com>
AuthorDate: Sat Oct 10 15:11:13 2026 +0800

    Plan no changes after importing row filters and column masks (#35)
---
 docs/resources/column_mask.md                   |  5 ++
 docs/resources/row_filter.md                    |  5 ++
 internal/provider/helpers.go                    | 16 ++++++
 internal/provider/helpers_test.go               | 14 +++++
 internal/provider/management_acceptance_test.go | 76 +++++++++++++++++++++++++
 internal/provider/resource_policy.go            |  7 ++-
 6 files changed, 121 insertions(+), 2 deletions(-)

diff --git a/docs/resources/column_mask.md b/docs/resources/column_mask.md
index 646a008..e73f088 100644
--- a/docs/resources/column_mask.md
+++ b/docs/resources/column_mask.md
@@ -105,6 +105,11 @@ terraform import paimon_column_mask.analyst_email \
   'database=analytics&table=events&principal=role%3Aanalyst&column=email'
 ```
 
+Import records `allow_non_atomic_update` as its default `false` and stores the
+server's `transform` in `jsonencode` form (sorted keys, no whitespace), so
+adopting a policy whose configuration omits the flag plans no changes. When the
+configuration sets the flag to `true`, the next apply only records it in state.
+
 The provider compares known Java policy AST semantics across create, refresh,
 and update: field references resolve by name against the current table schema,
 and one-child AND/OR predicates simplify to their child. Equivalent configured
diff --git a/docs/resources/row_filter.md b/docs/resources/row_filter.md
index 009dafd..85e30c7 100644
--- a/docs/resources/row_filter.md
+++ b/docs/resources/row_filter.md
@@ -109,6 +109,11 @@ terraform import paimon_row_filter.analyst_region \
   'database=analytics&table=events&principal=role%3Aanalyst'
 ```
 
+Import records `allow_non_atomic_update` as its default `false` and stores the
+server's `predicate` in `jsonencode` form (sorted keys, no whitespace), so
+adopting a policy whose configuration omits the flag plans no changes. When the
+configuration sets the flag to `true`, the next apply only records it in state.
+
 The provider compares known Java policy AST semantics across create, refresh,
 and update: field references resolve by name against the current table schema,
 and one-child AND/OR predicates simplify to their child. Equivalent configured
diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go
index 405f99b..0141d3f 100644
--- a/internal/provider/helpers.go
+++ b/internal/provider/helpers.go
@@ -123,6 +123,22 @@ func equivalentJSON(left, right string) bool {
 	return reflect.DeepEqual(leftValue, rightValue)
 }
 
+// compactJSON formats JSON like jsonencode, avoiding whitespace-only diffs.
+func compactJSON(value string) string {
+	var decoded any
+	decoder := json.NewDecoder(strings.NewReader(value))
+	decoder.UseNumber()
+	if decoder.Decode(&decoded) != nil || !json.Valid([]byte(value)) {
+		return value
+	}
+	encoded, err := json.Marshal(decoded)
+	if err != nil {
+		return value
+	}
+
+	return string(encoded)
+}
+
 func mutationRecoveryContext(ctx context.Context, api *client.Client) (context.Context, context.CancelFunc) {
 	// A canceled apply context can still follow a mutation that committed remotely.
 	// Keep request-scoped values, but give reconciliation and repair a fresh bound.
diff --git a/internal/provider/helpers_test.go b/internal/provider/helpers_test.go
index 182aa7d..d3c285d 100644
--- a/internal/provider/helpers_test.go
+++ b/internal/provider/helpers_test.go
@@ -62,3 +62,17 @@ func TestRetryLookupUntilReturnsLastStaleStateAtDeadline(t *testing.T) {
 	assert.False(t, converged)
 	assert.Equal(t, "stale", value)
 }
+
+func TestCompactJSONMatchesJSONEncode(t *testing.T) {
+	for name, tc := range map[string]struct{ in, want string }{
+		"whitespace":    {"{\n  \"name\": \"NULL\"\n}", `{"name":"NULL"}`},
+		"key order":     {`{"name":"CONCAT","inputs":["***"]}`, `{"inputs":["***"],"name":"CONCAT"}`},
+		"large integer": {`{"literals":[9007199254740993]}`, `{"literals":[9007199254740993]}`},
+		"html escaping": {`{"literals":["a<b&c>d"]}`, `{"literals":["a\u003cb\u0026c\u003ed"]}`},
+		"invalid":       {`{"name":`, `{"name":`},
+	} {
+		t.Run(name, func(t *testing.T) {
+			assert.Equal(t, tc.want, compactJSON(tc.in))
+		})
+	}
+}
diff --git a/internal/provider/management_acceptance_test.go b/internal/provider/management_acceptance_test.go
index 41ad155..9053ef9 100644
--- a/internal/provider/management_acceptance_test.go
+++ b/internal/provider/management_acceptance_test.go
@@ -16,6 +16,7 @@
 package provider
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -198,3 +199,78 @@ func TestAccManagementResourceLifecycle(t *testing.T) {
 	require.Empty(t, catalog.policies)
 	require.Empty(t, catalog.permissions)
 }
+
+func TestAccPolicyImportPlansNoChanges(t *testing.T) {
+	if os.Getenv("TF_ACC") == "" {
+		t.Skip("set TF_ACC=1 to run Terraform acceptance tests")
+	}
+	// Some servers return indented policy JSON.
+	indent := func(value string) string {
+		var out bytes.Buffer
+		require.NoError(t, json.Indent(&out, []byte(value), "", "  "))
+
+		return out.String()
+	}
+	catalog := &managementAcceptanceCatalog{policies: make(map[string]client.DataPolicy), canonicalize: func(p client.PolicyRequest) client.PolicyRequest {
+		if p.RowFilter != nil {
+			p.RowFilter = &client.RowFilter{Predicate: indent(p.RowFilter.Predicate)}
+		}
+		if p.ColumnMask != nil {
+			p.ColumnMask = &client.ColumnMask{OnColumn: p.ColumnMask.OnColumn, Transform: indent(p.ColumnMask.Transform)}
+		}
+
+		return p
+	}}
+	server := httptest.NewServer(catalog)
+	defer server.Close()
+	config := fmt.Sprintf("provider \"paimon\" { uri = %q }\n", server.URL) + managementTableConfig("analytics") + `
+resource "paimon_row_filter" "region" {
+  database = paimon_table.events.database
+  table = paimon_table.events.name
+  principal = "role:analyst"
+  predicate = jsonencode({
+    kind = "LEAF"
+    transform = { name = "FIELD_REF", fieldRef = { index = 1, name = "tenant", type = "STRING" } }
+    function = "EQUAL"
+    literals = ["APAC"]
+  })
+}
+resource "paimon_column_mask" "secret" {
+  database = paimon_table.events.database
+  table = paimon_table.events.name
+  principal = "role:analyst"
+  column = "secret"
+  transform = jsonencode({ name = "NULL" })
+}
+`
+	// Forget the policies without dropping them.
+	forget := fmt.Sprintf("provider \"paimon\" { uri = %q }\n", server.URL) + managementTableConfig("analytics") + `
+removed {
+  from = paimon_row_filter.region
+  lifecycle { destroy = false }
+}
+removed {
+  from = paimon_column_mask.secret
+  lifecycle { destroy = false }
+}
+`
+	resource.Test(t, resource.TestCase{
+		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+		Steps: []resource.TestStep{
+			{Config: config},
+			{Config: forget},
+			{
+				Config: config, ResourceName: "paimon_row_filter.region", ImportState: true, ImportStatePersist: true,
+				ImportStateId: "database=analytics&table=events&principal=role%3Aanalyst",
+			},
+			{
+				Config: config, ResourceName: "paimon_column_mask.secret", ImportState: true, ImportStatePersist: true,
+				ImportStateId: "database=analytics&table=events&principal=role%3Aanalyst&column=secret",
+			},
+			{Config: config, PlanOnly: true},
+		},
+	})
+	catalog.mu.Lock()
+	defer catalog.mu.Unlock()
+	require.Empty(t, catalog.policies)
+}
diff --git a/internal/provider/resource_policy.go b/internal/provider/resource_policy.go
index cf21f3b..9969608 100644
--- a/internal/provider/resource_policy.go
+++ b/internal/provider/resource_policy.go
@@ -313,6 +313,8 @@ func (r *rowFilterResource) ImportState(ctx context.Context, req resource.Import
 	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database"), model.Database)...)
 	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("table"), model.Table)...)
 	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("principal"), model.Principal)...)
+	// The server does not store this flag; import its default.
+	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("allow_non_atomic_update"), false)...)
 }
 
 func (r *rowFilterResource) readIntoState(ctx context.Context, model *rowFilterResourceModel, diags *diag.Diagnostics) bool {
@@ -333,7 +335,7 @@ func (r *rowFilterResource) readIntoState(ctx context.Context, model *rowFilterR
 		return false
 	}
 	if model.Predicate.IsNull() || model.Predicate.IsUnknown() || !equal {
-		model.Predicate = types.StringValue(remote)
+		model.Predicate = types.StringValue(compactJSON(remote))
 	}
 	model.ID = types.StringValue(rowFilterID(*model))
 
@@ -505,6 +507,7 @@ func (r *columnMaskResource) ImportState(ctx context.Context, req resource.Impor
 	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("table"), model.Table)...)
 	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("principal"), model.Principal)...)
 	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("column"), model.Column)...)
+	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("allow_non_atomic_update"), false)...)
 }
 
 func (r *columnMaskResource) readIntoState(ctx context.Context, model *columnMaskResourceModel, diags *diag.Diagnostics) bool {
@@ -525,7 +528,7 @@ func (r *columnMaskResource) readIntoState(ctx context.Context, model *columnMas
 		return false
 	}
 	if model.Transform.IsNull() || model.Transform.IsUnknown() || !equal {
-		model.Transform = types.StringValue(remote)
+		model.Transform = types.StringValue(compactJSON(remote))
 	}
 	model.ID = types.StringValue(columnMaskID(*model))