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

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 14e99c3842 [#13230] fix(api): defensively copy fieldNames and 
properties in IndexImpl (#13231)
14e99c3842 is described below

commit 14e99c3842236afbc234abfeec62cf02646131c0
Author: YangJie <[email protected]>
AuthorDate: Mon Sep 21 23:30:22 2026 -0400

    [#13230] fix(api): defensively copy fieldNames and properties in IndexImpl 
(#13231)
    
    ### What changes were proposed in this pull request?
    
    `IndexImpl` now deep-copies `fieldNames` at construction and on
    `fieldNames()`, preserving null rows (`row == null ? null :
    row.clone()`), and stores `properties` as a null-tolerant unmodifiable
    copy (`Collections.unmodifiableMap(new HashMap<>(properties))`, matching
    the sibling `TableChange.AddIndex`). `equals`/`hashCode` semantics are
    unchanged.
    
    ### Why are the changes needed?
    
    The index stored and returned the caller's `String[][]` and `Map` by
    reference, so mutating the caller's arrays before or after `Indexes.of`
    changed the built index and drifted its equality. The defensive copy
    keeps a built index immutable while still accepting the inputs the old
    code accepted, including a null field-name row and null property values
    (which `Arrays.deepEquals`/`deepHashCode` and the map comparison
    handle).
    
    Fix: #13230
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API change. A built index is now immutable: mutating the caller's
    arrays or map no longer changes the index. Inputs that were previously
    accepted (including a null field-name row or null property value) are
    still accepted. `equals`/`hashCode` semantics are unchanged.
    
    ### How was this patch tested?
    
    Added `TestIndexes` cases that pin: mutating the caller-supplied arrays
    and map does not change the built index; a null field-name row is
    accepted and compares correctly; and a null property value is accepted.
    They fail on the pre-fix tree and pass after the fix.
    
    ---------
    
    Co-authored-by: Jerry Shao <[email protected]>
---
 .../org/apache/gravitino/rel/indexes/Indexes.java  |  25 +++++-
 .../apache/gravitino/rel/indexes/TestIndexes.java  | 100 +++++++++++++++++++++
 .../lakehouse/paimon/TestGravitinoPaimonTable.java |   2 +-
 3 files changed, 122 insertions(+), 5 deletions(-)

diff --git a/api/src/main/java/org/apache/gravitino/rel/indexes/Indexes.java 
b/api/src/main/java/org/apache/gravitino/rel/indexes/Indexes.java
index 79fe95ef01..630c7ed73c 100644
--- a/api/src/main/java/org/apache/gravitino/rel/indexes/Indexes.java
+++ b/api/src/main/java/org/apache/gravitino/rel/indexes/Indexes.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.rel.indexes;
 import com.google.common.base.Objects;
 import com.google.common.collect.ImmutableMap;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.Map;
 import org.apache.gravitino.rel.indexes.Index.IndexType;
 
@@ -155,8 +157,19 @@ public class Indexes {
         IndexType indexType, String name, String[][] fieldNames, Map<String, 
String> properties) {
       this.indexType = indexType;
       this.name = name;
-      this.fieldNames = fieldNames;
-      this.properties = properties == null ? ImmutableMap.of() : properties;
+      // Deep-copy the caller's arrays and snapshot the map so later external 
mutations of the
+      // caller's inputs cannot change the built index. Null rows and null map 
entries are
+      // preserved rather than rejected, matching the pre-fix behavior and 
TableChange.AddIndex.
+      this.fieldNames =
+          fieldNames == null
+              ? null
+              : Arrays.stream(fieldNames)
+                  .map(row -> row == null ? null : row.clone())
+                  .toArray(String[][]::new);
+      this.properties =
+          properties == null
+              ? ImmutableMap.of()
+              : Collections.unmodifiableMap(new HashMap<>(properties));
     }
 
     /**
@@ -176,11 +189,15 @@ public class Indexes {
     }
 
     /**
-     * @return The field names under the table contained in the index
+     * @return A defensive copy of the field names under the table contained 
in the index
      */
     @Override
     public String[][] fieldNames() {
-      return fieldNames;
+      return fieldNames == null
+          ? null
+          : Arrays.stream(fieldNames)
+              .map(row -> row == null ? null : row.clone())
+              .toArray(String[][]::new);
     }
 
     /**
diff --git 
a/api/src/test/java/org/apache/gravitino/rel/indexes/TestIndexes.java 
b/api/src/test/java/org/apache/gravitino/rel/indexes/TestIndexes.java
new file mode 100644
index 0000000000..428449b878
--- /dev/null
+++ b/api/src/test/java/org/apache/gravitino/rel/indexes/TestIndexes.java
@@ -0,0 +1,100 @@
+/*
+ * 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 org.apache.gravitino.rel.indexes;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIndexes {
+
+  @Test
+  public void testIndexDefensiveCopies() {
+    String[][] fieldNames = {{"col1"}};
+    Map<String, String> properties = new HashMap<>();
+    properties.put("key", "value");
+
+    Index index = Indexes.of(Index.IndexType.PRIMARY_KEY, "idx", fieldNames, 
properties);
+
+    // Before the fix, IndexImpl stored the caller's arrays and map by 
reference and returned them
+    // directly, so external mutation leaked into the built index.
+    fieldNames[0][0] = "mutated";
+    properties.put("other", "injected");
+
+    Assertions.assertArrayEquals(new String[] {"col1"}, index.fieldNames()[0]);
+    Assertions.assertEquals(ImmutableMap.of("key", "value"), 
index.properties());
+  }
+
+  @Test
+  public void testIndexReturnedCollectionsAreIsolated() {
+    Index index =
+        Indexes.of(
+            Index.IndexType.UNIQUE_KEY,
+            "idx",
+            new String[][] {{"a", "b"}},
+            ImmutableMap.of("k", "v"));
+
+    // Mutating what the accessor returned must not change the index state 
either.
+    index.fieldNames()[0][0] = "mutated";
+    Assertions.assertEquals("a", index.fieldNames()[0][0]);
+    Assertions.assertThrows(
+        UnsupportedOperationException.class, () -> 
index.properties().put("k2", "v2"));
+  }
+
+  @Test
+  public void testNullFieldNameRowIsTolerated() {
+    // A null row must not blow up the defensive copy in either the 
constructor or fieldNames().
+    String[][] fieldNames = {{"col1"}, null};
+
+    Index index = Indexes.of(Index.IndexType.PRIMARY_KEY, "idx", fieldNames, 
ImmutableMap.of());
+
+    String[][] returned = index.fieldNames();
+    Assertions.assertArrayEquals(new String[] {"col1"}, returned[0]);
+    Assertions.assertNull(returned[1]);
+
+    // equals/hashCode must stay null-safe across the null row.
+    Index same =
+        Indexes.of(
+            Index.IndexType.PRIMARY_KEY, "idx", new String[][] {{"col1"}, 
null}, ImmutableMap.of());
+    Assertions.assertEquals(index, same);
+    Assertions.assertEquals(index.hashCode(), same.hashCode());
+  }
+
+  @Test
+  public void testNullPropertyValueIsTolerated() {
+    // Storing the map by reference used to tolerate null values; the 
defensive copy must too, to
+    // match the sibling TableChange.AddIndex contract.
+    Map<String, String> properties = new HashMap<>();
+    properties.put("key", null);
+
+    Index index =
+        Indexes.of(Index.IndexType.PRIMARY_KEY, "idx", new String[][] 
{{"col1"}}, properties);
+
+    Assertions.assertTrue(index.properties().containsKey("key"));
+    Assertions.assertNull(index.properties().get("key"));
+
+    // Still an immutable snapshot: neither the returned map nor later caller 
mutations leak.
+    Assertions.assertThrows(
+        UnsupportedOperationException.class, () -> 
index.properties().put("k2", "v2"));
+    properties.put("added", "later");
+    Assertions.assertFalse(index.properties().containsKey("added"));
+  }
+}
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestGravitinoPaimonTable.java
 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestGravitinoPaimonTable.java
index 23c2666054..87a7b72efc 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestGravitinoPaimonTable.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/test/java/org/apache/gravitino/catalog/lakehouse/paimon/TestGravitinoPaimonTable.java
@@ -313,7 +313,7 @@ public class TestGravitinoPaimonTable {
     for (int i = 0; i < indexes.length; i++) {
       Assertions.assertEquals(indexes[i].name(), table.index()[i].name());
       Assertions.assertEquals(indexes[i].type(), table.index()[i].type());
-      Assertions.assertEquals(indexes[i].fieldNames(), 
table.index()[i].fieldNames());
+      Assertions.assertArrayEquals(indexes[i].fieldNames(), 
table.index()[i].fieldNames());
     }
 
     Table loadedTable = paimonCatalogOperations.loadTable(tableIdentifier);

Reply via email to