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

yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 01b062cf9a [Cherry-pick to branch-1.3] [#13931] fix(lance): preserve 
externally owned registered datasets (#13932) (#13954)
01b062cf9a is described below

commit 01b062cf9a6bbf6b49064d2011dca28f1f0df5c4
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Oct 20 08:19:57 2026 +0800

    [Cherry-pick to branch-1.3] [#13931] fix(lance): preserve externally owned 
registered datasets (#13932) (#13954)
    
    **Cherry-pick Information:**
    - Original commit: f742f1100a5a67c198267965134e98a6d7daa2d9
    - Target branch: `branch-1.3`
    - Status: ⚠️ **Has conflicts - manual resolution required**
    
    **Do not merge** until conflict markers are resolved and the
    `cherry-pick-conflict` label is removed.
    
    Please review and resolve the conflicts before merging.
    
    ---------
    
    Co-authored-by: Qi Yu <[email protected]>
---
 .../generic/GenericCatalogOperations.java          |   2 +
 .../lakehouse/lance/LanceTableOperations.java      |  11 +-
 .../generic/TestGenericCatalogOperations.java      | 148 ++++++++++++++++++++
 .../lakehouse/lance/TestLanceTableOperations.java  | 152 +++++++++++++++++++++
 docs/lakehouse-generic-lance-table.md              |   9 +-
 .../gravitino/GravitinoLanceTableOperations.java   |   5 +-
 .../lance/common/utils/LancePropertiesUtils.java   |  48 +++++++
 .../common/utils/TestLancePropertiesUtils.java     |  47 +++++++
 .../TestGravitinoLanceTableOperations.java         |  40 ++++++
 9 files changed, 449 insertions(+), 13 deletions(-)

diff --git 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java
index 11497e4d68..5cc15f4e19 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/generic/GenericCatalogOperations.java
@@ -56,6 +56,7 @@ import org.apache.gravitino.exceptions.NoSuchTableException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
+import org.apache.gravitino.lance.common.utils.LancePropertiesUtils;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.Table;
@@ -233,6 +234,7 @@ public class GenericCatalogOperations implements 
CatalogOperations, SupportsSche
       Index[] indexes)
       throws NoSuchSchemaException, TableAlreadyExistsException {
     Schema schema = loadSchema(NameIdentifier.of(ident.namespace().levels()));
+    properties = LancePropertiesUtils.normalizeTableProperties(properties);
     String tableLocation = calculateTableLocation(schema, ident, properties);
 
     String format = properties.getOrDefault(Table.PROPERTY_TABLE_FORMAT, null);
diff --git 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
index 86debc4f03..344fcefe44 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
@@ -277,10 +277,7 @@ public class LanceTableOperations extends 
ManagedTableOperations {
       // Use super.loadTable to avoid triggering an unnecessary schema-refresh 
(which may open the
       // dataset) for a table that is about to be deleted anyway.
       Table table = super.loadTable(ident);
-      boolean external =
-          Optional.ofNullable(table.properties().get(Table.PROPERTY_EXTERNAL))
-              .map(Boolean::parseBoolean)
-              .orElse(false);
+      boolean external = 
LancePropertiesUtils.isExternalTable(table.properties());
 
       // NOTE: the two steps below (metadata removal and dataset deletion) are 
NOT atomic.
       // If the process crashes between them, the entity-store record will be 
gone but the
@@ -313,10 +310,7 @@ public class LanceTableOperations extends 
ManagedTableOperations {
     try {
       // Use super.loadTable to skip schema-refresh overhead when dropping.
       Table table = super.loadTable(ident);
-      boolean external =
-          Optional.ofNullable(table.properties().get(Table.PROPERTY_EXTERNAL))
-              .map(Boolean::parseBoolean)
-              .orElse(false);
+      boolean external = 
LancePropertiesUtils.isExternalTable(table.properties());
 
       // NOTE: the two steps below (metadata removal and dataset deletion) are 
NOT atomic.
       // If the process crashes between them, the entity-store record will be 
gone but the
@@ -376,6 +370,7 @@ public class LanceTableOperations extends 
ManagedTableOperations {
       throws NoSuchSchemaException, TableAlreadyExistsException {
 
     if (register) {
+      properties = LancePropertiesUtils.normalizeTableProperties(properties);
       // Currently, register operation does not read the schema from the 
underlying Lance dataset.
       // So we can't get the version of the dataset here.
       return super.createTable(
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
index fc0c0859c1..8d6c25795a 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/generic/TestGenericCatalogOperations.java
@@ -37,14 +37,21 @@ import static 
org.apache.gravitino.Configs.STORE_TRANSACTION_MAX_SKEW_TIME;
 import static org.apache.gravitino.Configs.VERSION_RETENTION_COUNT;
 import static org.mockito.Mockito.when;
 
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.Instant;
 import java.util.Arrays;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 import java.util.stream.Collectors;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Catalog;
@@ -64,6 +71,12 @@ import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.SchemaVersion;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -71,6 +84,11 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.lance.Dataset;
+import org.lance.WriteParams;
 import org.mockito.Mockito;
 
 public class TestGenericCatalogOperations {
@@ -145,6 +163,136 @@ public class TestGenericCatalogOperations {
     ops = new GenericCatalogOperations(store, idGenerator);
   }
 
+  @TempDir private Path lanceDataDir;
+
+  /** Registered tables must be persisted as external whatever the caller 
passed. */
+  @ParameterizedTest
+  @CsvSource(
+      value = {"NULL", "false", "true"},
+      nullValues = "NULL")
+  void testRegisterPropertiesAreExternal(String external) {
+    GenericCatalogOperations lanceOps = initializedOps();
+    NameIdentifier schemaIdent = createSchema();
+    NameIdentifier ident =
+        NameIdentifier.of(METALAKE_NAME, CATALOG_NAME, schemaIdent.name(), 
"registered");
+    Map<String, String> properties =
+        Maps.newHashMap(
+            Map.of(
+                Table.PROPERTY_TABLE_FORMAT,
+                "lance",
+                "lance.register",
+                "true",
+                Table.PROPERTY_LOCATION,
+                lanceDataDir.resolve("existing").toString()));
+    properties.putAll(
+        
StringIdentifier.newPropertiesWithId(StringIdentifier.fromId(idGenerator.nextId()),
 null));
+    if (external != null) {
+      properties.put(Table.PROPERTY_EXTERNAL, external);
+    }
+    Map<String, String> original = Map.copyOf(properties);
+
+    Table created =
+        lanceOps.createTable(
+            ident,
+            new Column[0],
+            null,
+            properties,
+            new Transform[0],
+            null,
+            new SortOrder[0],
+            new Index[0]);
+
+    Assertions.assertEquals("true", 
created.properties().get(Table.PROPERTY_EXTERNAL));
+    Assertions.assertEquals(original, properties);
+    Assertions.assertTrue(lanceOps.dropTable(ident));
+  }
+
+  /** Legacy registrations preserve real data on drop and schema cascade; 
purge deletes it. */
+  @ParameterizedTest
+  @CsvSource(
+      value = {
+        "NULL,drop",
+        "false,drop",
+        "NULL,purge",
+        "false,purge",
+        "NULL,cascade",
+        "false,cascade"
+      },
+      nullValues = "NULL")
+  void testLegacyRegisteredLanceOwnership(String external, String operation) 
throws Exception {
+    GenericCatalogOperations lanceOps = initializedOps();
+    NameIdentifier schemaIdent = createSchema();
+    String location = lanceDataDir.resolve("legacy").toString();
+    // Arrow Schema and Gravitino Schema share a simple name in this test 
class.
+    try (Dataset ignored =
+        Dataset.write()
+            .schema(
+                new org.apache.arrow.vector.types.pojo.Schema(
+                    List.of(Field.nullable("id", new ArrowType.Int(32, 
true)))))
+            .uri(location)
+            .mode(WriteParams.WriteMode.CREATE)
+            .execute()) {
+      // The dataset predates its catalog registration.
+    }
+    NameIdentifier ident =
+        NameIdentifier.of(METALAKE_NAME, CATALOG_NAME, schemaIdent.name(), 
"legacy");
+    Map<String, String> properties =
+        Maps.newHashMap(
+            Map.of(
+                Table.PROPERTY_TABLE_FORMAT,
+                "lance",
+                "lance.register",
+                "true",
+                Table.PROPERTY_LOCATION,
+                location));
+    if (external != null) {
+      properties.put(Table.PROPERTY_EXTERNAL, external);
+    }
+    TableEntity entity =
+        TableEntity.builder()
+            .withId(idGenerator.nextId())
+            .withName(ident.name())
+            .withNamespace(ident.namespace())
+            .withProperties(properties)
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+            .build();
+    store.put(entity, false);
+
+    if ("purge".equals(operation)) {
+      Assertions.assertTrue(lanceOps.purgeTable(ident));
+    } else if ("cascade".equals(operation)) {
+      Assertions.assertTrue(lanceOps.dropSchema(schemaIdent, true));
+    } else {
+      Assertions.assertTrue(lanceOps.dropTable(ident));
+    }
+
+    Assertions.assertFalse(lanceOps.tableExists(ident));
+    if ("purge".equals(operation)) {
+      
Assertions.assertFalse(Files.exists(lanceDataDir.resolve("legacy/_versions")));
+    } else {
+      try (Dataset dataset = Dataset.open().uri(location).build()) {
+        Assertions.assertEquals("id", 
dataset.getSchema().getFields().get(0).getName());
+      }
+    }
+  }
+
+  private static GenericCatalogOperations initializedOps() {
+    GenericCatalogOperations initialized = new GenericCatalogOperations(store, 
idGenerator);
+    initialized.initialize(ImmutableMap.of(), null /* CatalogInfo, unused */, 
new GenericCatalog());
+    return initialized;
+  }
+
+  private NameIdentifier createSchema() {
+    NameIdentifier schemaIdent =
+        NameIdentifierUtil.ofSchema(METALAKE_NAME, CATALOG_NAME, 
randomSchemaName());
+    ops.createSchema(
+        schemaIdent,
+        null,
+        
StringIdentifier.newPropertiesWithId(StringIdentifier.fromId(idGenerator.nextId()),
 null));
+    return schemaIdent;
+  }
+
   @AfterAll
   public static void tearDown() throws IOException {
     ops.close();
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
index 3545e2beda..530cfe9bd2 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
@@ -36,6 +36,8 @@ import static org.mockito.Mockito.when;
 
 import com.google.common.collect.Maps;
 import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
@@ -47,6 +49,7 @@ import org.apache.arrow.vector.types.pojo.Schema;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.StringIdentifier;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.catalog.ManagedSchemaOperations;
 import org.apache.gravitino.meta.AuditInfo;
@@ -66,9 +69,11 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.lance.Dataset;
 import org.lance.Version;
+import org.lance.WriteParams;
 import org.lance.index.IndexOptions;
 import org.mockito.InOrder;
 import org.mockito.Mockito;
@@ -90,6 +95,145 @@ public class TestLanceTableOperations {
     lanceTableOps = spy(new LanceTableOperations(store, schemaOps, 
idGenerator));
   }
 
+  /** Registered tables persist external ownership and retain the existing 
dataset on drop. */
+  @ParameterizedTest
+  @CsvSource(
+      value = {"NULL", "false", "true"},
+      nullValues = "NULL")
+  void testRegisterPersistsExternalAndDropPreservesDataset(String external) 
throws Exception {
+    NameIdentifier ident = NameIdentifier.of("schema", "registered");
+    String location = tempDir.resolve("registered").toString();
+    Column[] columns = {Column.of("id", Types.IntegerType.get())};
+    when(idGenerator.nextId()).thenReturn(10L);
+    try (Dataset ignored = writeDataset(location)) {
+      // Seed a dataset owned by the caller before registering it.
+    }
+    Map<String, String> properties = Maps.newHashMap();
+    properties.put(Table.PROPERTY_TABLE_FORMAT, "lance");
+    properties.put(Table.PROPERTY_LOCATION, location);
+    properties.put(LANCE_TABLE_REGISTER, "true");
+    
properties.putAll(StringIdentifier.newPropertiesWithId(StringIdentifier.fromId(1L),
 null));
+    if (external != null) {
+      properties.put(Table.PROPERTY_EXTERNAL, external);
+    }
+    Map<String, String> original = Map.copyOf(properties);
+    AtomicReference<TableEntity> stored = new AtomicReference<>();
+    Mockito.doAnswer(
+            invocation -> {
+              stored.set(invocation.getArgument(0));
+              return null;
+            })
+        .when(store)
+        .put(any(TableEntity.class), eq(false));
+    when(store.get(eq(ident), eq(Entity.EntityType.TABLE), 
eq(TableEntity.class)))
+        .thenAnswer(invocation -> stored.get());
+    when(store.delete(ident, Entity.EntityType.TABLE)).thenReturn(true);
+
+    Table created =
+        PrincipalUtils.doAs(
+            new UserPrincipal("tester"),
+            () ->
+                lanceTableOps.createTable(
+                    ident,
+                    columns,
+                    null,
+                    properties,
+                    new Transform[0],
+                    null,
+                    new SortOrder[0],
+                    new Index[0]));
+
+    Assertions.assertEquals("true", 
created.properties().get(Table.PROPERTY_EXTERNAL));
+    Assertions.assertEquals("true", 
stored.get().properties().get(Table.PROPERTY_EXTERNAL));
+    Assertions.assertEquals(
+        "true", 
lanceTableOps.loadTable(ident).properties().get(Table.PROPERTY_EXTERNAL));
+    Assertions.assertEquals(original, properties);
+    Assertions.assertTrue(lanceTableOps.dropTable(ident));
+    verify(store).delete(ident, Entity.EntityType.TABLE);
+    try (Dataset dataset = Dataset.open().uri(location).build()) {
+      Assertions.assertEquals("id", 
dataset.getSchema().getFields().get(0).getName());
+    }
+  }
+
+  /** Legacy registration flags also protect drop and overwrite, while purge 
remains destructive. */
+  @ParameterizedTest
+  @CsvSource(
+      value = {
+        "NULL,drop",
+        "false,drop",
+        "true,drop",
+        "NULL,purge",
+        "false,purge",
+        "true,purge",
+        "NULL,overwrite",
+        "false,overwrite"
+      },
+      nullValues = "NULL")
+  void testLegacyRegistrationRemoval(String external, String operation) throws 
Exception {
+    NameIdentifier ident = NameIdentifier.of("schema", "legacy");
+    String location = tempDir.resolve("legacy").toString();
+    Column[] columns = {Column.of("id", Types.IntegerType.get())};
+    when(idGenerator.nextId()).thenReturn(10L);
+    try (Dataset ignored = writeDataset(location)) {
+      // A historical registration must never transfer ownership to Gravitino 
implicitly.
+    }
+    Map<String, String> properties =
+        Maps.newHashMap(
+            Map.of(
+                Table.PROPERTY_TABLE_FORMAT,
+                "lance",
+                Table.PROPERTY_LOCATION,
+                location,
+                LANCE_TABLE_REGISTER,
+                "true"));
+    if (external != null) {
+      properties.put(Table.PROPERTY_EXTERNAL, external);
+    }
+    AtomicReference<TableEntity> stored =
+        new AtomicReference<>(tableEntity(ident, List.of(), properties));
+    when(store.get(eq(ident), eq(Entity.EntityType.TABLE), 
eq(TableEntity.class)))
+        .thenAnswer(invocation -> stored.get());
+    when(store.delete(ident, Entity.EntityType.TABLE)).thenReturn(true);
+    if ("overwrite".equals(operation)) {
+      Map<String, String> replacement = Maps.newHashMap(properties);
+      replacement.put(LANCE_CREATION_MODE, "OVERWRITE");
+      
replacement.putAll(StringIdentifier.newPropertiesWithId(StringIdentifier.fromId(2L),
 null));
+      Mockito.doAnswer(
+              invocation -> {
+                stored.set(invocation.getArgument(0));
+                return null;
+              })
+          .when(store)
+          .put(any(TableEntity.class), eq(false));
+      Table replaced =
+          PrincipalUtils.doAs(
+              new UserPrincipal("tester"),
+              () ->
+                  lanceTableOps.createTable(
+                      ident,
+                      columns,
+                      null,
+                      replacement,
+                      new Transform[0],
+                      null,
+                      new SortOrder[0],
+                      new Index[0]));
+      Assertions.assertEquals("true", 
replaced.properties().get(Table.PROPERTY_EXTERNAL));
+    } else if ("purge".equals(operation)) {
+      Assertions.assertTrue(lanceTableOps.purgeTable(ident));
+    } else {
+      Assertions.assertTrue(lanceTableOps.dropTable(ident));
+    }
+    verify(store).delete(ident, Entity.EntityType.TABLE);
+    if ("purge".equals(operation)) {
+      Assertions.assertFalse(Files.exists(Paths.get(location, "_versions")));
+    } else {
+      try (Dataset dataset = Dataset.open().uri(location).build()) {
+        Assertions.assertEquals("id", 
dataset.getSchema().getFields().get(0).getName());
+      }
+    }
+  }
+
   @Test
   public void testCreationModeEnum() {
     // Test that CreationMode enum has expected values
@@ -1149,4 +1293,12 @@ public class TestLanceTableOperations {
               return updated;
             });
   }
+
+  private static Dataset writeDataset(String location) {
+    return Dataset.write()
+        .schema(new Schema(List.of(Field.nullable("id", new ArrowType.Int(32, 
true)))))
+        .uri(location)
+        .mode(WriteParams.WriteMode.CREATE)
+        .execute();
+  }
 }
diff --git a/docs/lakehouse-generic-lance-table.md 
b/docs/lakehouse-generic-lance-table.md
index 2961527486..0b3bbeb3b9 100644
--- a/docs/lakehouse-generic-lance-table.md
+++ b/docs/lakehouse-generic-lance-table.md
@@ -203,7 +203,14 @@ tableCatalog.createTable(
 
 #### Register External Tables
 
-Register existing Lance tables without moving or copying data:
+Register existing Lance tables without moving or copying data. A table with 
`lance.register=true`
+is always external, including when the request omits `external` or sets it to 
`false`. Dropping it
+through the Gravitino table API removes only the metadata. Explicit 
`purgeTable` and Lance REST
+`drop` still delete both the metadata and the dataset; use Lance REST 
`deregister` to preserve data.
+
+Existing registrations with a missing or false stored `external` flag receive 
the same protection
+when dropped or deregistered. Their stored properties are not automatically 
migrated.
+
 
 <Tabs groupId='language' queryString>
 <TabItem value="shell" label="Shell">
diff --git 
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
 
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
index deab801fee..a658aed474 100644
--- 
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
+++ 
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
@@ -293,10 +293,7 @@ public class GravitinoLanceTableOperations implements 
LanceTableOperations {
     // dropTable would delete the underlying Lance data, violating the 
deregister contract
     // ("It will not delete the underlying lance data"). The TableCatalog 
interface does not
     // expose a metadata-only removal path, so we fail fast rather than risk 
silent data loss.
-    boolean external =
-        Optional.ofNullable(properties.get(Table.PROPERTY_EXTERNAL))
-            .map(Boolean::parseBoolean)
-            .orElse(false);
+    boolean external = LancePropertiesUtils.isExternalTable(properties);
     if (!external) {
       throw new UnsupportedOperationException(
           "deregisterTable only supports external tables, but table " + 
tableId + " is managed");
diff --git 
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LancePropertiesUtils.java
 
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LancePropertiesUtils.java
index 4d235afcf8..29cb5db621 100644
--- 
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LancePropertiesUtils.java
+++ 
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/LancePropertiesUtils.java
@@ -20,11 +20,14 @@
 package org.apache.gravitino.lance.common.utils;
 
 import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_STORAGE_OPTIONS_PREFIX;
+import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_FORMAT;
+import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_REGISTER;
 
 import com.google.common.base.Preconditions;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.stream.Collectors;
+import org.apache.gravitino.rel.Table;
 
 /** Utility methods for Lance storage properties. */
 public final class LancePropertiesUtils {
@@ -33,6 +36,46 @@ public final class LancePropertiesUtils {
     // Utility class.
   }
 
+  /**
+   * Returns whether the supplied table format identifies a Lance table.
+   *
+   * @param tableFormat the table format, which may be null
+   * @return true when the format is Lance, ignoring case
+   */
+  public static boolean isLanceTableFormat(String tableFormat) {
+    return LANCE_TABLE_FORMAT.equalsIgnoreCase(tableFormat);
+  }
+
+  /**
+   * Returns whether the table's data is externally owned.
+   *
+   * <p>Registered Lance tables are external even when an older registration 
omitted or incorrectly
+   * stored the external flag. Other formats retain their explicit external 
flag semantics.
+   *
+   * @param tableProperties the stored table properties
+   * @return true for an explicitly external table or a registered Lance table
+   */
+  public static boolean isExternalTable(Map<String, String> tableProperties) {
+    return Boolean.parseBoolean(tableProperties.get(Table.PROPERTY_EXTERNAL))
+        || isRegisteredLanceTable(tableProperties);
+  }
+
+  /**
+   * Makes registered Lance table properties explicitly external without 
changing the input map.
+   *
+   * @param tableProperties the table properties
+   * @return a copy with external=true for a registered Lance table, or the 
input when unchanged
+   */
+  public static Map<String, String> normalizeTableProperties(Map<String, 
String> tableProperties) {
+    if (!isRegisteredLanceTable(tableProperties)
+        || "true".equals(tableProperties.get(Table.PROPERTY_EXTERNAL))) {
+      return tableProperties;
+    }
+    Map<String, String> normalized = new LinkedHashMap<>(tableProperties);
+    normalized.put(Table.PROPERTY_EXTERNAL, "true");
+    return normalized;
+  }
+
   /**
    * Extracts Lance storage options from a property map.
    *
@@ -95,4 +138,9 @@ public final class LancePropertiesUtils {
                 Collectors.toMap(
                     entry -> LANCE_STORAGE_OPTIONS_PREFIX + entry.getKey(), 
Map.Entry::getValue));
   }
+
+  private static boolean isRegisteredLanceTable(Map<String, String> 
tableProperties) {
+    return isLanceTableFormat(tableProperties.get(Table.PROPERTY_TABLE_FORMAT))
+        && Boolean.parseBoolean(tableProperties.get(LANCE_TABLE_REGISTER));
+  }
 }
diff --git 
a/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestLancePropertiesUtils.java
 
b/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestLancePropertiesUtils.java
index 51de5b37e1..128f352b61 100644
--- 
a/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestLancePropertiesUtils.java
+++ 
b/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestLancePropertiesUtils.java
@@ -18,15 +18,62 @@
  */
 package org.apache.gravitino.lance.common.utils;
 
+import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_FORMAT;
+
 import com.google.common.collect.ImmutableMap;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 public class TestLancePropertiesUtils {
 
+  /** Verifies the binary Lance format predicate's case and null handling. */
+  @Test
+  public void testIsLanceTableFormatIsCaseInsensitiveAndNullSafe() {
+    
Assertions.assertTrue(LancePropertiesUtils.isLanceTableFormat(LANCE_TABLE_FORMAT));
+    Assertions.assertTrue(LancePropertiesUtils.isLanceTableFormat("LANCE"));
+    Assertions.assertFalse(LancePropertiesUtils.isLanceTableFormat("delta"));
+    Assertions.assertFalse(LancePropertiesUtils.isLanceTableFormat(null));
+  }
+
+  /** Checks ownership without extending Lance registration semantics to other 
formats. */
+  @ParameterizedTest
+  @CsvSource(
+      value = {
+        "lance,true,NULL,true", "LANCE,TRUE,false,true", 
"lance,true,true,true",
+        "lance,false,false,false", "lance,NULL,NULL,false", 
"lance,false,true,true",
+        "delta,true,false,false", "delta,true,true,true", 
"NULL,true,false,false"
+      },
+      nullValues = "NULL")
+  void testEffectiveExternalOwnership(
+      String format, String register, String external, boolean expected) {
+    Map<String, String> properties = new HashMap<>();
+    if (format != null) {
+      properties.put("format", format);
+    }
+    if (register != null) {
+      properties.put("lance.register", register);
+    }
+    if (external != null) {
+      properties.put("external", external);
+    }
+    Map<String, String> original = Map.copyOf(properties);
+
+    Assertions.assertEquals(expected, 
LancePropertiesUtils.isExternalTable(properties));
+    Map<String, String> normalized = 
LancePropertiesUtils.normalizeTableProperties(properties);
+    if (LancePropertiesUtils.isLanceTableFormat(format) && 
Boolean.parseBoolean(register)) {
+      Assertions.assertEquals("true", normalized.get("external"));
+    } else {
+      Assertions.assertSame(properties, normalized);
+    }
+    Assertions.assertEquals(original, properties);
+  }
+
   @Test
   public void testGetLanceStorageOptions() {
     Map<String, String> properties =
diff --git 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceTableOperations.java
 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceTableOperations.java
index a291e80ce1..9af8133485 100644
--- 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceTableOperations.java
+++ 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceTableOperations.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.lance.common.ops.gravitino;
 
 import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_VERSION;
 
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -97,6 +98,37 @@ class TestGravitinoLanceTableOperations {
         "Only RENAME alteration is supported currently.", 
exception.getMessage());
   }
 
+  @Test
+  void testDeregisterLegacyRegisteredTable() {
+    for (String external : Arrays.asList(null, "false", "true")) {
+      Table table = Mockito.mock(Table.class);
+      Map<String, String> properties =
+          new HashMap<>(
+              Map.of(
+                  Table.PROPERTY_TABLE_FORMAT,
+                  "lance",
+                  "lance.register",
+                  "true",
+                  "location",
+                  "/existing"));
+      if (external != null) {
+        properties.put(Table.PROPERTY_EXTERNAL, external);
+      }
+      Mockito.when(table.properties()).thenReturn(properties);
+      TableCatalog tableCatalog = Mockito.mock(TableCatalog.class);
+      
Mockito.when(tableCatalog.loadTable(Mockito.any(NameIdentifier.class))).thenReturn(table);
+      
Mockito.when(tableCatalog.dropTable(Mockito.any(NameIdentifier.class))).thenReturn(true);
+
+      Assertions.assertEquals(
+          "/existing",
+          operations(tableCatalog).deregisterTable("catalog.schema.table", 
".").getLocation(),
+          "external=" + external);
+
+      Mockito.verify(tableCatalog).dropTable(NameIdentifier.of("schema", 
"table"));
+      Mockito.verify(tableCatalog, Mockito.never()).purgeTable(Mockito.any());
+    }
+  }
+
   @Test
   void testDeregisterTableRejectsManagedTable() {
     // Mock a managed table (no PROPERTY_EXTERNAL=true)
@@ -126,4 +158,12 @@ class TestGravitinoLanceTableOperations {
     // Verify dropTable was never called — the guard must reject before 
reaching the catalog layer.
     Mockito.verify(tableCatalog, Mockito.never()).dropTable(Mockito.any());
   }
+
+  private static GravitinoLanceTableOperations operations(TableCatalog 
tableCatalog) {
+    Catalog catalog = Mockito.mock(Catalog.class);
+    GravitinoLanceNamespaceWrapper wrapper = 
Mockito.mock(GravitinoLanceNamespaceWrapper.class);
+    
Mockito.when(wrapper.loadAndValidateLakehouseCatalog(Mockito.anyString())).thenReturn(catalog);
+    Mockito.when(wrapper.asTableCatalog(catalog)).thenReturn(tableCatalog);
+    return new GravitinoLanceTableOperations(wrapper);
+  }
 }

Reply via email to