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

diqiu50 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 b2b15b65b3 [Cherry-pick to branch-1.3] [#12996] fix(catalog-glue): 
Derive table location from the Glue database LocationUri (#12997) (#13038)
b2b15b65b3 is described below

commit b2b15b65b38ce9642d6456360edf78e0ee08e413
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Sep 10 09:10:21 2026 +0800

    [Cherry-pick to branch-1.3] [#12996] fix(catalog-glue): Derive table 
location from the Glue database LocationUri (#12997) (#13038)
    
    **Cherry-pick Information:**
    - Original commit: 0e38553643676c3cbd38f39d7392d9af09f8e76e
    - Target branch: `branch-1.3`
    - Status: ✅ Clean cherry-pick (no conflicts)
    
    Co-authored-by: Yuhui <[email protected]>
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../gravitino/catalog/glue/GlueConstants.java      |   6 +-
 .../catalog/glue/GlueCatalogOperations.java        |  33 +++-
 .../glue/GlueCatalogPropertiesMetadata.java        |   5 +-
 .../glue/TestGlueCatalogOperationsForIceberg.java  |   7 +
 .../glue/TestGlueCatalogTableOperations.java       | 195 +++++++++++++++++++++
 docs/aws-glue-catalog.md                           |   6 +-
 6 files changed, 239 insertions(+), 13 deletions(-)

diff --git 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
index 76de4483d5..da78dfe24c 100644
--- 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
+++ 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/glue/GlueConstants.java
@@ -66,8 +66,10 @@ public final class GlueConstants {
 
   /**
    * Base storage path used as a warehouse when no explicit {@code location} 
is given at table
-   * creation time. The table location is derived as {@code 
warehouse/database/table}. Example:
-   * {@code s3://my-bucket/gravitino-warehouse}.
+   * creation time and the Glue database declares no {@code LocationUri}. The 
table location is
+   * derived as {@code warehouse/database/table}.
+   *
+   * <p>Example warehouse: {@code s3://my-bucket/gravitino-warehouse}.
    */
   public static final String WAREHOUSE = "warehouse";
 
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
index e7ce0ea8b0..67a8a80738 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
@@ -122,7 +122,10 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
 
   @VisibleForTesting String defaultTableFormat;
 
-  /** Warehouse storage path. Table location is derived as {@code 
warehouse/db/table}. */
+  /**
+   * Warehouse storage path. Table location is derived as {@code 
warehouse/db/table} for databases
+   * that declare no {@code LocationUri}.
+   */
   @VisibleForTesting String warehouseLocation;
 
   /** Iceberg SDK Glue catalog used for creating Iceberg-format tables. */
@@ -824,16 +827,23 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
         .build();
   }
 
+  /**
+   * Resolves the storage location of a table, in order of precedence: the 
explicit {@code location}
+   * property, the {@code LocationUri} the Glue database declares, and finally 
the catalog warehouse
+   * path. A database location already identifies the database, so the table 
name is appended
+   * directly to it, whereas the warehouse path is shared by all databases and 
needs the database
+   * name in between.
+   */
   private String resolveTableLocation(String explicitLocation, String dbName, 
String tableName) {
     if (explicitLocation != null) {
       return explicitLocation;
     }
+    String databaseLocation = databaseLocationUri(dbName);
+    if (StringUtils.isNotBlank(databaseLocation)) {
+      return StringUtils.stripEnd(databaseLocation, "/") + "/" + tableName;
+    }
     if (StringUtils.isNotBlank(warehouseLocation)) {
-      String base =
-          warehouseLocation.endsWith("/")
-              ? warehouseLocation.substring(0, warehouseLocation.length() - 1)
-              : warehouseLocation;
-      return base + "/" + dbName + "/" + tableName;
+      return StringUtils.stripEnd(warehouseLocation, "/") + "/" + dbName + "/" 
+ tableName;
     }
     throw new IllegalArgumentException(
         "Table location is required: either set the '"
@@ -843,6 +853,17 @@ public class GlueCatalogOperations implements 
CatalogOperations, SupportsSchemas
             + "' on the catalog.");
   }
 
+  /** Returns the {@code LocationUri} of the given Glue database, or null when 
it declares none. */
+  private String databaseLocationUri(String dbName) {
+    GetDatabaseRequest.Builder req = GetDatabaseRequest.builder().name(dbName);
+    applyCatalogId(catalogId, req::catalogId);
+    try {
+      return glueClient.getDatabase(req.build()).database().locationUri();
+    } catch (GlueException e) {
+      throw GlueExceptionConverter.toSchemaException(e, "schema " + dbName);
+    }
+  }
+
   private static void applyColumnChange(
       List<Column> dataCols, List<Column> partCols, TableChange.ColumnChange 
change) {
 
diff --git 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogPropertiesMetadata.java
 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogPropertiesMetadata.java
index 47e5bf31c6..7275861ab7 100644
--- 
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogPropertiesMetadata.java
@@ -105,8 +105,9 @@ public class GlueCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
               stringRequiredPropertyEntry(
                   WAREHOUSE,
                   "Base storage path used as warehouse when no explicit 
location is set"
-                      + " at table creation time (e.g. 
s3://my-bucket/warehouse)."
-                      + " Table location is derived as 
warehouse/database/table.",
+                      + " at table creation time and the database declares no 
LocationUri"
+                      + " (e.g. s3://my-bucket/warehouse)."
+                      + " Table location is then derived as 
warehouse/database/table.",
                   false /* immutable */,
                   false /* hidden */))
           .build();
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
index 8970b74a4a..3fe0f11d96 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogOperationsForIceberg.java
@@ -47,6 +47,9 @@ import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 import software.amazon.awssdk.services.glue.GlueClient;
+import software.amazon.awssdk.services.glue.model.Database;
+import software.amazon.awssdk.services.glue.model.GetDatabaseRequest;
+import software.amazon.awssdk.services.glue.model.GetDatabaseResponse;
 import software.amazon.awssdk.services.glue.model.GetPartitionsRequest;
 import software.amazon.awssdk.services.glue.model.GetPartitionsResponse;
 import software.amazon.awssdk.services.glue.model.GetTableRequest;
@@ -193,6 +196,10 @@ class TestGlueCatalogOperationsForIceberg {
     GlueColumn[] cols = {
       
GlueColumn.builder().withName("id").withType(Types.LongType.get()).withNullable(false).build()
     };
+    // The database declares no location either, so there is nothing to derive 
the location from.
+    when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+        .thenReturn(
+            
GetDatabaseResponse.builder().database(Database.builder().name(DB).build()).build());
 
     assertThrows(
         IllegalArgumentException.class,
diff --git 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
index b8ed3206e8..d2da93aafc 100644
--- 
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
+++ 
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogTableOperations.java
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -52,8 +53,11 @@ import org.mockito.ArgumentCaptor;
 import software.amazon.awssdk.services.glue.GlueClient;
 import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
 import software.amazon.awssdk.services.glue.model.CreateTableRequest;
+import software.amazon.awssdk.services.glue.model.Database;
 import software.amazon.awssdk.services.glue.model.DeleteTableRequest;
 import software.amazon.awssdk.services.glue.model.EntityNotFoundException;
+import software.amazon.awssdk.services.glue.model.GetDatabaseRequest;
+import software.amazon.awssdk.services.glue.model.GetDatabaseResponse;
 import software.amazon.awssdk.services.glue.model.GetTableRequest;
 import software.amazon.awssdk.services.glue.model.GetTableResponse;
 import software.amazon.awssdk.services.glue.model.GetTablesRequest;
@@ -74,6 +78,14 @@ class TestGlueCatalogTableOperations {
     ops = new GlueCatalogOperations();
     ops.glueClient = mockClient;
     ops.warehouseLocation = "s3://test-bucket/warehouse";
+    stubDatabaseLocation(null);
+  }
+
+  /** Stubs the database lookup used to resolve table locations. */
+  private void stubDatabaseLocation(String locationUri) {
+    Database database = 
Database.builder().name("mydb").locationUri(locationUri).build();
+    when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+        .thenReturn(GetDatabaseResponse.builder().database(database).build());
   }
 
   // -------------------------------------------------------------------------
@@ -274,6 +286,163 @@ class TestGlueCatalogTableOperations {
     
assertFalse(req.tableInput().parameters().containsKey(GlueConstants.LOCATION));
   }
 
+  @Test
+  void testCreateTableLocationFromDatabaseLocationUri() {
+    stubDatabaseLocation("s3://test-bucket/gravprobe");
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"mytable");
+
+    ArgumentCaptor<CreateTableRequest> captor = 
ArgumentCaptor.forClass(CreateTableRequest.class);
+
+    ops.createTable(
+        ident,
+        new Column[0],
+        "comment",
+        Collections.emptyMap(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        SortOrders.NONE,
+        Indexes.EMPTY_INDEXES);
+
+    verify(mockClient).createTable(captor.capture());
+    assertEquals(
+        "s3://test-bucket/gravprobe/mytable",
+        captor.getValue().tableInput().storageDescriptor().location());
+  }
+
+  @Test
+  void testCreateTableLocationFromDatabaseLocationUriWithTrailingSlashes() {
+    stubDatabaseLocation("s3://test-bucket/gravprobe///");
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"mytable");
+
+    ArgumentCaptor<CreateTableRequest> captor = 
ArgumentCaptor.forClass(CreateTableRequest.class);
+
+    ops.createTable(
+        ident,
+        new Column[0],
+        "comment",
+        Collections.emptyMap(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        SortOrders.NONE,
+        Indexes.EMPTY_INDEXES);
+
+    verify(mockClient).createTable(captor.capture());
+    assertEquals(
+        "s3://test-bucket/gravprobe/mytable",
+        captor.getValue().tableInput().storageDescriptor().location());
+  }
+
+  @Test
+  void testCreateTableLocationWhenWarehouseEqualsDatabaseLocation() {
+    ops.warehouseLocation = "s3://test-bucket/gravprobe";
+    stubDatabaseLocation("s3://test-bucket/gravprobe");
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"mytable");
+
+    ArgumentCaptor<CreateTableRequest> captor = 
ArgumentCaptor.forClass(CreateTableRequest.class);
+
+    ops.createTable(
+        ident,
+        new Column[0],
+        "comment",
+        Collections.emptyMap(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        SortOrders.NONE,
+        Indexes.EMPTY_INDEXES);
+
+    verify(mockClient).createTable(captor.capture());
+    assertEquals(
+        "s3://test-bucket/gravprobe/mytable",
+        captor.getValue().tableInput().storageDescriptor().location());
+  }
+
+  @Test
+  void testCreateTableLocationFallsBackToWarehouse() {
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"mytable");
+
+    ArgumentCaptor<CreateTableRequest> captor = 
ArgumentCaptor.forClass(CreateTableRequest.class);
+
+    ops.createTable(
+        ident,
+        new Column[0],
+        "comment",
+        Collections.emptyMap(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        SortOrders.NONE,
+        Indexes.EMPTY_INDEXES);
+
+    verify(mockClient).createTable(captor.capture());
+    assertEquals(
+        "s3://test-bucket/warehouse/mydb/mytable",
+        captor.getValue().tableInput().storageDescriptor().location());
+  }
+
+  @Test
+  void testCreateTableLocationFromWarehouseWithTrailingSlashes() {
+    ops.warehouseLocation = "s3://test-bucket/warehouse///";
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"mytable");
+
+    ArgumentCaptor<CreateTableRequest> captor = 
ArgumentCaptor.forClass(CreateTableRequest.class);
+
+    ops.createTable(
+        ident,
+        new Column[0],
+        "comment",
+        Collections.emptyMap(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        SortOrders.NONE,
+        Indexes.EMPTY_INDEXES);
+
+    verify(mockClient).createTable(captor.capture());
+    assertEquals(
+        "s3://test-bucket/warehouse/mydb/mytable",
+        captor.getValue().tableInput().storageDescriptor().location());
+  }
+
+  @Test
+  void testCreateTableExplicitLocationWinsOverDatabaseLocationUri() {
+    stubDatabaseLocation("s3://test-bucket/gravprobe");
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"mytable");
+
+    ArgumentCaptor<CreateTableRequest> captor = 
ArgumentCaptor.forClass(CreateTableRequest.class);
+
+    ops.createTable(
+        ident,
+        new Column[0],
+        "comment",
+        Map.of(GlueConstants.LOCATION, "s3://my-bucket/path"),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        SortOrders.NONE,
+        Indexes.EMPTY_INDEXES);
+
+    verify(mockClient).createTable(captor.capture());
+    assertEquals(
+        "s3://my-bucket/path", 
captor.getValue().tableInput().storageDescriptor().location());
+  }
+
+  @Test
+  void testCreateTableSchemaNotFoundWhileResolvingLocation() {
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "missing", 
"mytable");
+    when(mockClient.getDatabase(any(GetDatabaseRequest.class)))
+        .thenThrow(EntityNotFoundException.builder().message("not 
found").build());
+
+    assertThrows(
+        NoSuchSchemaException.class,
+        () ->
+            ops.createTable(
+                ident,
+                new Column[0],
+                "comment",
+                Collections.emptyMap(),
+                Transforms.EMPTY_TRANSFORM,
+                Distributions.NONE,
+                SortOrders.NONE,
+                Indexes.EMPTY_INDEXES));
+  }
+
   // -------------------------------------------------------------------------
   // alterTable
   // -------------------------------------------------------------------------
@@ -299,6 +468,32 @@ class TestGlueCatalogTableOperations {
     assertEquals("new comment", result.comment());
   }
 
+  @Test
+  void testAlterTableKeepsExistingLocation() {
+    stubDatabaseLocation("s3://test-bucket/gravprobe");
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"t");
+    Table glueTable =
+        Table.builder()
+            .name("t")
+            .storageDescriptor(
+                
StorageDescriptor.builder().location("s3://other-bucket/existing/t").build())
+            .createTime(Instant.now())
+            .build();
+    when(mockClient.getTable(any(GetTableRequest.class)))
+        .thenReturn(GetTableResponse.builder().table(glueTable).build());
+    when(mockClient.updateTable(any(UpdateTableRequest.class)))
+        .thenReturn(UpdateTableResponse.builder().build());
+
+    ArgumentCaptor<UpdateTableRequest> captor = 
ArgumentCaptor.forClass(UpdateTableRequest.class);
+    ops.alterTable(ident, TableChange.updateComment("new comment"));
+
+    verify(mockClient).updateTable(captor.capture());
+    assertEquals(
+        "s3://other-bucket/existing/t",
+        captor.getValue().tableInput().storageDescriptor().location());
+    verify(mockClient, never()).getDatabase(any(GetDatabaseRequest.class));
+  }
+
   @Test
   void testAlterTableSetProperty() {
     NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "mydb", 
"t");
diff --git a/docs/aws-glue-catalog.md b/docs/aws-glue-catalog.md
index 590f5bae2e..6e1b7fb70a 100644
--- a/docs/aws-glue-catalog.md
+++ b/docs/aws-glue-catalog.md
@@ -42,7 +42,7 @@ Besides the [common catalog 
properties](./gravitino-server-config.md#catalog-pro
 | `aws-access-key-id`     | AWS access key ID for static credential 
authentication. When omitted, the default credential chain is used.             
                                                                            | 
(none)        | No       | No        |
 | `aws-secret-access-key` | AWS secret access key paired with 
`aws-access-key-id`. When omitted, the default credential chain is used.        
                                                                                
  | (none)        | No       | No        |
 | `aws-glue-endpoint`     | Custom Glue endpoint URL for VPC endpoints or 
LocalStack testing (e.g. `http://localhost:4566`).                              
                                                                      | (none)  
      | No       | No        |
-| `warehouse`             | Base storage path used as the warehouse when no 
explicit `location` is specified at table creation time (e.g. 
`s3://my-bucket/warehouse`). Table location is derived as 
`warehouse/database/table`. | (none)        | Yes      | No        |
+| `warehouse`             | Base storage path used as the warehouse when no 
explicit `location` is specified at table creation time and the Glue database 
declares no `LocationUri` (e.g. `s3://my-bucket/warehouse`). Table location is 
then derived as `warehouse/database/table`. | (none)        | Yes      | No     
   |
 | `default-table-format`  | Default format for tables created via Gravitino's 
`createTable()` API. Accepted values: `iceberg`, `hive`.                        
                                                                  | `hive`      
  | No       | No        |
 | `table-format-filter`   | Comma-separated list of table formats exposed by 
`listTables()` and `loadTable()`. Accepted values: `all`, `hive`, `iceberg`, 
`delta`, `parquet`. Use to restrict visible table types.              | `all`   
      | No       | No        |
 
@@ -148,7 +148,7 @@ The following table lists predefined properties for Glue 
tables. Additional key-
 
 | Property Name       | Description                                            
                                                                                
                                                                      | Default 
Value                                                | Required | Reserved | 
Immutable |
 
|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------|----------|----------|-----------|
-| `location`          | The location for table storage, such as 
`s3://bucket/prefix/test_table`. Derived from `warehouse/database/table` when 
not specified.                                                                  
       | (derived from warehouse)                                     | No      
 | No       | No        |
+| `location`          | The location for table storage, such as 
`s3://bucket/prefix/test_table`. When not specified, it is derived from the 
`LocationUri` of the Glue database as `database-location/table`, falling back 
to `warehouse/database/table` when the database declares no location.           
                                                              | (derived from 
database location or warehouse)                | No       | No       | No       
 |
 | `format`            | The table file format (`parquet`, `orc`, `textfile`, 
etc.). When set, `input-format`, `output-format`, and `serde-lib` are derived 
automatically. Used primarily when creating Hive-format tables via Trino. | 
(none)                                                       | No       | No    
   | Yes       |
 | `input-format`      | The input format class for the table, such as 
`org.apache.hadoop.hive.ql.io.orc.OrcInputFormat`.                              
                                                                               
| `org.apache.hadoop.mapred.TextInputFormat`                   | No       | No  
     | Yes       |
 | `output-format`     | The output format class for the table, such as 
`org.apache.hadoop.hive.ql.io.orc.OrcOutputFormat`.                             
                                                                              | 
`org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat` | No       | No    
   | Yes       |
@@ -214,7 +214,7 @@ The Glue catalog supports creating and managing 
Iceberg-format tables through th
 
 Set `table-format=ICEBERG` in the table properties, or configure 
`default-table-format=iceberg` on the catalog to make all tables Iceberg by 
default.
 
-The `warehouse` catalog property must be configured. The table location is 
derived as `warehouse/database/table` when no explicit `location` is specified.
+The `warehouse` catalog property is required, but it is used only as a 
fallback when no explicit `location` is specified and the Glue database 
declares no `LocationUri`. Without an explicit `location`, the table location 
is derived as `database-location/table` when the database declares a 
`LocationUri`, or as `warehouse/database/table` otherwise.
 
 ### Register an Existing Iceberg Table
 

Reply via email to