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

roryqi 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 eb8e6d7520 [#11037] feat(core): Hierarchical namespace persistence and 
owner batch in relational service (#11036)
eb8e6d7520 is described below

commit eb8e6d75208f0f64c034b48b1b8124c6f9ce697e
Author: roryqi <[email protected]>
AuthorDate: Wed May 13 16:01:38 2026 +0800

    [#11037] feat(core): Hierarchical namespace persistence and owner batch in 
relational service (#11036)
    
    ### What changes were proposed in this pull request?
    
    - Hierarchical schema config/util; schema meta service hierarchical
    naming
    - Owner batch soft-delete/insert SQL (OwnerRelDeletion) and
    OwnerMetaService.batchSetOwners
        - Schema/owner mappers and SQL providers; StatisticPO adjustments
        - PO converter tests
    
    ### Why are the changes needed?
    
    Fix: #11037
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Added UTs.
---
 .../main/java/org/apache/gravitino/Configs.java    |  21 +++
 .../storage/relational/mapper/OwnerMetaMapper.java |  10 ++
 .../mapper/OwnerMetaSQLProviderFactory.java        |  10 ++
 .../relational/mapper/SchemaMetaMapper.java        |   8 +
 .../mapper/SchemaMetaSQLProviderFactory.java       |   9 ++
 .../provider/base/OwnerMetaBaseSQLProvider.java    |  30 ++++
 .../provider/base/SchemaMetaBaseSQLProvider.java   |  39 +++++
 .../postgresql/OwnerMetaPostgreSQLProvider.java    |  17 +++
 .../postgresql/SchemaMetaPostgreSQLProvider.java   |  27 ++++
 .../storage/relational/po/OwnerRelForDeletion.java |  30 ++++
 .../storage/relational/po/StatisticPO.java         |  15 +-
 .../relational/service/OwnerMetaService.java       |  46 ++++++
 .../relational/service/SchemaMetaService.java      |  82 +++++++++-
 .../relational/service/StatisticMetaService.java   |   4 +-
 .../gravitino/utils/HierarchicalSchemaUtil.java    | 166 +++++++++++++++++++++
 .../storage/relational/BackendTestExtension.java   |   3 +
 .../relational/service/TestOwnerMetaService.java   | 149 ++++++++++++++++++
 .../relational/service/TestSchemaMetaService.java  | 109 ++++++++++++++
 .../storage/relational/utils/TestPOConverters.java |   5 +-
 .../utils/TestHierarchicalSchemaUtil.java          | 144 ++++++++++++++++++
 20 files changed, 914 insertions(+), 10 deletions(-)

diff --git a/core/src/main/java/org/apache/gravitino/Configs.java 
b/core/src/main/java/org/apache/gravitino/Configs.java
index 3cc9d703e6..2f09aa54c8 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -29,6 +29,7 @@ import org.apache.gravitino.config.ConfigBuilder;
 import org.apache.gravitino.config.ConfigConstants;
 import org.apache.gravitino.config.ConfigEntry;
 import org.apache.gravitino.stats.storage.JdbcPartitionStatisticStorageFactory;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 
 public class Configs {
 
@@ -500,6 +501,26 @@ public class Configs {
           .checkValue(value -> value > 0, 
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
           .createWithDefault(5 * 60 * 1000L); // Default is 5 minutes
 
+  public static final ConfigEntry<String> SCHEMA_SEPARATOR =
+      new ConfigBuilder("gravitino.schema.separator")
+          .doc(
+              "The separator used to represent HierarchicalSchema hierarchy in 
schema names at the "
+                  + "API boundary (e.g. ':' for 'A:B:C'). Schema names are 
stored internally in "
+                  + "EntityStore using ASCII-1 (\\u0001) as the physical 
separator. The "
+                  + "configured separator is only used at external API and 
catalog capability "
+                  + "validation layer. The internal physical separator and '.' 
must not be used "
+                  + "as the external separator.")
+          .version(ConfigConstants.VERSION_1_3_0)
+          .stringConf()
+          .checkValue(
+              value ->
+                  StringUtils.isNotBlank(value)
+                      && !value.contains(".")
+                      && 
!value.contains(HierarchicalSchemaUtil.physicalSeparator()),
+              ConfigConstants.NOT_BLANK_ERROR_MSG
+                  + " and must not contain '.' or the internal physical 
separator (\\u0001)")
+          .createWithDefault(":");
+
   public static final ConfigEntry<String> 
PARTITION_STATS_STORAGE_FACTORY_CLASS =
       new ConfigBuilder("gravitino.stats.partition.storageFactoryClass")
           .doc(
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
index 3a2d0c459b..b2872ac3b2 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaMapper.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.storage.relational.mapper;
 
 import java.util.List;
 import org.apache.gravitino.storage.relational.po.GroupPO;
+import org.apache.gravitino.storage.relational.po.OwnerRelForDeletion;
 import org.apache.gravitino.storage.relational.po.OwnerRelPO;
 import org.apache.gravitino.storage.relational.po.UserOwnerRelPO;
 import org.apache.gravitino.storage.relational.po.UserPO;
@@ -66,6 +67,15 @@ public interface OwnerMetaMapper {
   @InsertProvider(type = OwnerMetaSQLProviderFactory.class, method = 
"insertOwnerRel")
   void insertOwnerRel(@Param("ownerRelPO") OwnerRelPO ownerRelPO);
 
+  @InsertProvider(type = OwnerMetaSQLProviderFactory.class, method = 
"batchInsertOwnerRels")
+  void batchInsertOwnerRels(@Param("ownerRelPOs") List<OwnerRelPO> 
ownerRelPOs);
+
+  @UpdateProvider(
+      type = OwnerMetaSQLProviderFactory.class,
+      method = "batchSoftDeleteOwnerRelByMetadataObjects")
+  void batchSoftDeleteOwnerRelByMetadataObjects(
+      @Param("deletions") List<OwnerRelForDeletion> deletions);
+
   @UpdateProvider(
       type = OwnerMetaSQLProviderFactory.class,
       method = "softDeleteOwnerRelByMetadataObjectIdAndType")
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
index a710583abf..3d805459c8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/OwnerMetaSQLProviderFactory.java
@@ -24,6 +24,7 @@ import java.util.Map;
 import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.OwnerMetaBaseSQLProvider;
 import 
org.apache.gravitino.storage.relational.mapper.provider.postgresql.OwnerMetaPostgreSQLProvider;
+import org.apache.gravitino.storage.relational.po.OwnerRelForDeletion;
 import org.apache.gravitino.storage.relational.po.OwnerRelPO;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.ibatis.annotations.Param;
@@ -69,6 +70,15 @@ public class OwnerMetaSQLProviderFactory {
     return getProvider().insertOwnerRel(ownerRelPO);
   }
 
+  public static String batchInsertOwnerRels(@Param("ownerRelPOs") 
List<OwnerRelPO> ownerRelPOs) {
+    return getProvider().batchInsertOwnerRels(ownerRelPOs);
+  }
+
+  public static String batchSoftDeleteOwnerRelByMetadataObjects(
+      @Param("deletions") List<OwnerRelForDeletion> deletions) {
+    return getProvider().batchSoftDeleteOwnerRelByMetadataObjects(deletions);
+  }
+
   public static String softDeleteOwnerRelByMetadataObjectIdAndType(
       @Param("metadataObjectId") Long metadataObjectId,
       @Param("metadataObjectType") String metadataObjectType) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SchemaMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SchemaMetaMapper.java
index 3473395942..d549ad49c6 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SchemaMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SchemaMetaMapper.java
@@ -82,6 +82,14 @@ public interface SchemaMetaMapper {
       method = "insertSchemaMetaOnDuplicateKeyUpdate")
   void insertSchemaMetaOnDuplicateKeyUpdate(@Param("schemaMeta") SchemaPO 
schemaPO);
 
+  @InsertProvider(type = SchemaMetaSQLProviderFactory.class, method = 
"batchInsertSchemaMeta")
+  void batchInsertSchemaMeta(@Param("schemaMetas") List<SchemaPO> schemaMetas);
+
+  @InsertProvider(
+      type = SchemaMetaSQLProviderFactory.class,
+      method = "batchInsertSchemaMetaOnDuplicateKeyUpdate")
+  void batchInsertSchemaMetaOnDuplicateKeyUpdate(@Param("schemaMetas") 
List<SchemaPO> schemaMetas);
+
   @UpdateProvider(type = SchemaMetaSQLProviderFactory.class, method = 
"updateSchemaMeta")
   Integer updateSchemaMeta(
       @Param("newSchemaMeta") SchemaPO newSchemaPO, @Param("oldSchemaMeta") 
SchemaPO oldSchemaPO);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SchemaMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SchemaMetaSQLProviderFactory.java
index 0cef49e994..46de84cff9 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SchemaMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/SchemaMetaSQLProviderFactory.java
@@ -94,6 +94,15 @@ public class SchemaMetaSQLProviderFactory {
     return getProvider().insertSchemaMetaOnDuplicateKeyUpdate(schemaPO);
   }
 
+  public static String batchInsertSchemaMeta(@Param("schemaMetas") 
List<SchemaPO> schemaMetas) {
+    return getProvider().batchInsertSchemaMeta(schemaMetas);
+  }
+
+  public static String batchInsertSchemaMetaOnDuplicateKeyUpdate(
+      @Param("schemaMetas") List<SchemaPO> schemaMetas) {
+    return 
getProvider().batchInsertSchemaMetaOnDuplicateKeyUpdate(schemaMetas);
+  }
+
   public static String updateSchemaMeta(
       @Param("newSchemaMeta") SchemaPO newSchemaPO, @Param("oldSchemaMeta") 
SchemaPO oldSchemaPO) {
     return getProvider().updateSchemaMeta(newSchemaPO, oldSchemaPO);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
index 27bcea1b92..5ec2fbdd89 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/OwnerMetaBaseSQLProvider.java
@@ -31,6 +31,7 @@ import 
org.apache.gravitino.storage.relational.mapper.TableMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.TopicMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.UserMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.ViewMetaMapper;
+import org.apache.gravitino.storage.relational.po.OwnerRelForDeletion;
 import org.apache.gravitino.storage.relational.po.OwnerRelPO;
 import org.apache.ibatis.annotations.Param;
 
@@ -124,6 +125,35 @@ public class OwnerMetaBaseSQLProvider {
         + ")";
   }
 
+  public String batchInsertOwnerRels(@Param("ownerRelPOs") List<OwnerRelPO> 
ownerRelPOs) {
+    return "<script>"
+        + "INSERT INTO "
+        + OWNER_TABLE_NAME
+        + " (metalake_id, metadata_object_id, metadata_object_type, owner_id, 
owner_type,"
+        + " audit_info, current_version, last_version, deleted_at, updated_at) 
VALUES "
+        + "<foreach collection='ownerRelPOs' item='po' separator=','>"
+        + "(#{po.metalakeId}, #{po.metadataObjectId}, 
#{po.metadataObjectType},"
+        + " #{po.ownerId}, #{po.ownerType}, #{po.auditInfo},"
+        + " #{po.currentVersion}, #{po.lastVersion}, #{po.deletedAt}, 
#{po.updatedAt})"
+        + "</foreach>"
+        + "</script>";
+  }
+
+  public String batchSoftDeleteOwnerRelByMetadataObjects(
+      @Param("deletions") List<OwnerRelForDeletion> deletions) {
+    return "<script>"
+        + "UPDATE "
+        + OWNER_TABLE_NAME
+        + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
+        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " WHERE deleted_at = 0 AND ("
+        + "<foreach collection='deletions' item='t' separator=' OR '>"
+        + "(metadata_object_id = #{t.metadataObjectId} AND 
metadata_object_type = #{t.metadataObjectType})"
+        + "</foreach>"
+        + ")"
+        + "</script>";
+  }
+
   public String softDeleteOwnerRelByMetadataObjectIdAndType(
       @Param("metadataObjectId") Long metadataObjectId,
       @Param("metadataObjectType") String metadataObjectType) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SchemaMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SchemaMetaBaseSQLProvider.java
index 202dc84593..63f10ae2fc 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SchemaMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/SchemaMetaBaseSQLProvider.java
@@ -200,6 +200,45 @@ public class SchemaMetaBaseSQLProvider {
         + " deleted_at = #{schemaMeta.deletedAt}";
   }
 
+  public String batchInsertSchemaMeta(@Param("schemaMetas") List<SchemaPO> 
schemaMetas) {
+    return "<script>"
+        + "INSERT INTO "
+        + TABLE_NAME
+        + " (schema_id, schema_name, metalake_id, catalog_id, schema_comment,"
+        + " properties, audit_info, current_version, last_version, deleted_at) 
VALUES "
+        + "<foreach collection='schemaMetas' item='po' separator=','>"
+        + "(#{po.schemaId}, #{po.schemaName}, #{po.metalakeId}, 
#{po.catalogId},"
+        + " #{po.schemaComment}, #{po.properties}, #{po.auditInfo},"
+        + " #{po.currentVersion}, #{po.lastVersion}, #{po.deletedAt})"
+        + "</foreach>"
+        + "</script>";
+  }
+
+  public String batchInsertSchemaMetaOnDuplicateKeyUpdate(
+      @Param("schemaMetas") List<SchemaPO> schemaMetas) {
+    return "<script>"
+        + "INSERT INTO "
+        + TABLE_NAME
+        + " (schema_id, schema_name, metalake_id, catalog_id, schema_comment,"
+        + " properties, audit_info, current_version, last_version, deleted_at) 
VALUES "
+        + "<foreach collection='schemaMetas' item='po' separator=','>"
+        + "(#{po.schemaId}, #{po.schemaName}, #{po.metalakeId}, 
#{po.catalogId},"
+        + " #{po.schemaComment}, #{po.properties}, #{po.auditInfo},"
+        + " #{po.currentVersion}, #{po.lastVersion}, #{po.deletedAt})"
+        + "</foreach>"
+        + " ON DUPLICATE KEY UPDATE"
+        + " schema_name = VALUES(schema_name),"
+        + " metalake_id = VALUES(metalake_id),"
+        + " catalog_id = VALUES(catalog_id),"
+        + " schema_comment = VALUES(schema_comment),"
+        + " properties = VALUES(properties),"
+        + " audit_info = VALUES(audit_info),"
+        + " current_version = VALUES(current_version),"
+        + " last_version = VALUES(last_version),"
+        + " deleted_at = VALUES(deleted_at)"
+        + "</script>";
+  }
+
   public String updateSchemaMeta(
       @Param("newSchemaMeta") SchemaPO newSchemaPO, @Param("oldSchemaMeta") 
SchemaPO oldSchemaPO) {
     return "UPDATE "
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
index d0fe369261..c0ba865250 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/OwnerMetaPostgreSQLProvider.java
@@ -20,6 +20,7 @@ package 
org.apache.gravitino.storage.relational.mapper.provider.postgresql;
 
 import static 
org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper.OWNER_TABLE_NAME;
 
+import java.util.List;
 import org.apache.gravitino.storage.relational.mapper.CatalogMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.FilesetMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.FunctionMetaMapper;
@@ -29,6 +30,7 @@ import 
org.apache.gravitino.storage.relational.mapper.TableMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.TopicMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.ViewMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.OwnerMetaBaseSQLProvider;
+import org.apache.gravitino.storage.relational.po.OwnerRelForDeletion;
 import org.apache.ibatis.annotations.Param;
 
 public class OwnerMetaPostgreSQLProvider extends OwnerMetaBaseSQLProvider {
@@ -148,6 +150,21 @@ public class OwnerMetaPostgreSQLProvider extends 
OwnerMetaBaseSQLProvider {
         + ")";
   }
 
+  @Override
+  public String batchSoftDeleteOwnerRelByMetadataObjects(
+      @Param("deletions") List<OwnerRelForDeletion> deletions) {
+    return "<script>"
+        + "UPDATE "
+        + OWNER_TABLE_NAME
+        + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
+        + " WHERE deleted_at = 0 AND ("
+        + "<foreach collection='deletions' item='t' separator=' OR '>"
+        + "(metadata_object_id = #{t.metadataObjectId} AND 
metadata_object_type = #{t.metadataObjectType})"
+        + "</foreach>"
+        + ")"
+        + "</script>";
+  }
+
   @Override
   public String deleteOwnerMetasByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SchemaMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SchemaMetaPostgreSQLProvider.java
index 1e23111c3f..e272c0a806 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SchemaMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/SchemaMetaPostgreSQLProvider.java
@@ -20,6 +20,7 @@ package 
org.apache.gravitino.storage.relational.mapper.provider.postgresql;
 
 import static 
org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper.TABLE_NAME;
 
+import java.util.List;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.SchemaMetaBaseSQLProvider;
 import org.apache.gravitino.storage.relational.po.SchemaPO;
 import org.apache.ibatis.annotations.Param;
@@ -56,6 +57,32 @@ public class SchemaMetaPostgreSQLProvider extends 
SchemaMetaBaseSQLProvider {
         + " deleted_at = #{schemaMeta.deletedAt}";
   }
 
+  @Override
+  public String batchInsertSchemaMetaOnDuplicateKeyUpdate(
+      @Param("schemaMetas") List<SchemaPO> schemaMetas) {
+    return "<script>"
+        + "INSERT INTO "
+        + TABLE_NAME
+        + " (schema_id, schema_name, metalake_id, catalog_id, schema_comment,"
+        + " properties, audit_info, current_version, last_version, deleted_at) 
VALUES "
+        + "<foreach collection='schemaMetas' item='po' separator=','>"
+        + "(#{po.schemaId}, #{po.schemaName}, #{po.metalakeId}, 
#{po.catalogId},"
+        + " #{po.schemaComment}, #{po.properties}, #{po.auditInfo},"
+        + " #{po.currentVersion}, #{po.lastVersion}, #{po.deletedAt})"
+        + "</foreach>"
+        + " ON CONFLICT(schema_id) DO UPDATE SET"
+        + " schema_name = EXCLUDED.schema_name,"
+        + " metalake_id = EXCLUDED.metalake_id,"
+        + " catalog_id = EXCLUDED.catalog_id,"
+        + " schema_comment = EXCLUDED.schema_comment,"
+        + " properties = EXCLUDED.properties,"
+        + " audit_info = EXCLUDED.audit_info,"
+        + " current_version = EXCLUDED.current_version,"
+        + " last_version = EXCLUDED.last_version,"
+        + " deleted_at = EXCLUDED.deleted_at"
+        + "</script>";
+  }
+
   @Override
   public String updateSchemaMeta(
       @Param("newSchemaMeta") SchemaPO newSchemaPO, @Param("oldSchemaMeta") 
SchemaPO oldSchemaPO) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/po/OwnerRelForDeletion.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/po/OwnerRelForDeletion.java
new file mode 100644
index 0000000000..3933b74a1f
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/po/OwnerRelForDeletion.java
@@ -0,0 +1,30 @@
+/*
+ * 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.storage.relational.po;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/** Key for batch soft-deleting owner rows by metadata object. */
+@Getter
+@AllArgsConstructor
+public class OwnerRelForDeletion {
+  private final Long metadataObjectId;
+  private final String metadataObjectType;
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/po/StatisticPO.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/po/StatisticPO.java
index fa3de359b2..0b6117d4c0 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/po/StatisticPO.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/po/StatisticPO.java
@@ -20,11 +20,15 @@ package org.apache.gravitino.storage.relational.po;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.google.common.base.Preconditions;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import lombok.Getter;
 import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
 import org.apache.gravitino.json.JsonUtils;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.StatisticEntity;
@@ -55,13 +59,22 @@ public class StatisticPO {
     return new Builder();
   }
 
-  public static StatisticEntity fromStatisticPO(StatisticPO statisticPO) {
+  public static StatisticEntity fromStatisticPO(
+      StatisticPO statisticPO, NameIdentifier metadataObjectIdentifier) {
+    Preconditions.checkArgument(
+        metadataObjectIdentifier != null, "`metadataObjectIdentifier` is 
required");
     try {
       return StatisticEntity.builder(
               StatisticEntity.getStatisticType(
                   MetadataObject.Type.valueOf(statisticPO.metadataObjectType)))
           .withId(statisticPO.getStatisticId())
           .withName(statisticPO.getStatisticName())
+          .withNamespace(
+              Namespace.of(
+                  Stream.concat(
+                          
Arrays.stream(metadataObjectIdentifier.namespace().levels()),
+                          Stream.of(metadataObjectIdentifier.name()))
+                      .toArray(String[]::new)))
           .withValue(
               JsonUtils.anyFieldMapper()
                   .readValue(statisticPO.getStatisticValue(), 
StatisticValue.class))
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/OwnerMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/OwnerMetaService.java
index 2a209e654f..012d00abc4 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/OwnerMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/OwnerMetaService.java
@@ -39,6 +39,7 @@ import org.apache.gravitino.meta.UserEntity;
 import org.apache.gravitino.metrics.Monitored;
 import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
 import org.apache.gravitino.storage.relational.po.GroupPO;
+import org.apache.gravitino.storage.relational.po.OwnerRelForDeletion;
 import org.apache.gravitino.storage.relational.po.OwnerRelPO;
 import org.apache.gravitino.storage.relational.po.UserOwnerRelPO;
 import org.apache.gravitino.storage.relational.po.UserPO;
@@ -172,4 +173,49 @@ public class OwnerMetaService {
             SessionUtils.doWithoutCommit(
                 OwnerMetaMapper.class, mapper -> 
mapper.insertOwnerRel(ownerRelPO)));
   }
+
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "batchSetOwner")
+  public void batchSetOwners(
+      List<NameIdentifier> ownedObjects,
+      Entity.EntityType ownedObjectType,
+      NameIdentifier ownerIdent,
+      Entity.EntityType ownerType) {
+    if (CollectionUtils.isEmpty(ownedObjects)) {
+      return;
+    }
+
+    String metalake = NameIdentifierUtil.getMetalake(ownedObjects.get(0));
+    for (NameIdentifier entity : ownedObjects) {
+      Preconditions.checkArgument(
+          Objects.equals(NameIdentifierUtil.getMetalake(entity), metalake),
+          "All owned objects must be in the same metalake");
+    }
+
+    long metalakeId = 
MetalakeMetaService.getInstance().getMetalakeIdByName(metalake);
+    Long ownerId = EntityIdService.getEntityId(ownerIdent, ownerType);
+
+    List<OwnerRelForDeletion> deletions = new ArrayList<>(ownedObjects.size());
+    List<OwnerRelPO> ownerRelPOs = new ArrayList<>(ownedObjects.size());
+    for (NameIdentifier entity : ownedObjects) {
+      Long entityId = EntityIdService.getEntityId(entity, ownedObjectType);
+      deletions.add(
+          new OwnerRelForDeletion(
+              entityId,
+              NameIdentifierUtil.toMetadataObject(entity, 
ownedObjectType).type().name()));
+      ownerRelPOs.add(
+          POConverters.initializeOwnerRelPOsWithVersion(
+              metalakeId, ownerType.name(), ownerId, ownedObjectType.name(), 
entityId));
+    }
+
+    SessionUtils.doMultipleWithCommit(
+        () ->
+            SessionUtils.doWithoutCommit(
+                OwnerMetaMapper.class,
+                mapper -> 
mapper.batchSoftDeleteOwnerRelByMetadataObjects(deletions)),
+        () ->
+            SessionUtils.doWithoutCommit(
+                OwnerMetaMapper.class, mapper -> 
mapper.batchInsertOwnerRels(ownerRelPOs)));
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
index 8127ae4c11..c7edb6d26f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
@@ -22,10 +22,15 @@ import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATI
 
 import com.google.common.base.Preconditions;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Function;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.Entity.EntityType;
@@ -43,6 +48,7 @@ import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.meta.TopicEntity;
 import org.apache.gravitino.metrics.Monitored;
+import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.storage.relational.helper.SchemaIds;
 import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
 import org.apache.gravitino.storage.relational.mapper.FilesetMetaMapper;
@@ -67,6 +73,7 @@ import 
org.apache.gravitino.storage.relational.po.cache.OperateType;
 import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
 import org.apache.gravitino.storage.relational.utils.POConverters;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
 
@@ -162,18 +169,71 @@ public class SchemaMetaService {
   public void insertSchema(SchemaEntity schemaEntity, boolean overwrite) 
throws IOException {
     try {
       NameIdentifierUtil.checkSchema(schemaEntity.nameIdentifier());
-
-      SchemaPO.Builder builder = SchemaPO.builder();
-      fillSchemaPOBuilderParentEntityId(builder, schemaEntity.namespace());
+      // Callers above this service (e.g. JDBCBackend + naming bridge) pass 
storage-form schema
+      // names: nested paths use the internal physical separator, not the 
external logical one.
+      String physicalSep = HierarchicalSchemaUtil.physicalSeparator();
+      String schemaName = schemaEntity.name();
+      List<SchemaEntity> rowsToInsert = new ArrayList<>();
+      if (schemaName == null || !schemaName.contains(physicalSep)) {
+        rowsToInsert.add(schemaEntity);
+      } else {
+        // Segments of the storage-form name; e.g. [A, B, C] -> ancestor rows 
"A", "A"+sep+"B", then
+        // leaf.
+        String[] parts = schemaName.split(Pattern.quote(physicalSep), -1);
+        for (int nSeg = 1; nSeg < parts.length; nSeg++) {
+          String ancestorPhysical = String.join(physicalSep, 
Arrays.copyOf(parts, nSeg));
+          SchemaEntity ancestor =
+              SchemaEntity.builder()
+                  .withId(nextIdForNestedAncestor())
+                  .withName(ancestorPhysical)
+                  .withNamespace(schemaEntity.namespace())
+                  .withComment(null)
+                  .withProperties(Collections.emptyMap())
+                  .withAuditInfo(schemaEntity.auditInfo())
+                  .build();
+          rowsToInsert.add(ancestor);
+        }
+        rowsToInsert.add(schemaEntity);
+      }
 
       SessionUtils.doWithCommit(
           SchemaMetaMapper.class,
           mapper -> {
-            SchemaPO po = 
POConverters.initializeSchemaPOWithVersion(schemaEntity, builder);
+            int n = rowsToInsert.size();
+            List<SchemaPO> missingAncestorPOs = new ArrayList<>();
+            if (n > 1) {
+              SchemaEntity firstAncestor = rowsToInsert.get(0);
+              Namespace ancestorNs = firstAncestor.namespace();
+              List<String> ancestorPhysicalNames =
+                  rowsToInsert.subList(0, n - 1).stream()
+                      .map(SchemaEntity::name)
+                      .collect(Collectors.toList());
+              Set<String> existingAncestorNames =
+                  mapper
+                      .batchSelectSchemaByIdentifier(
+                          ancestorNs.level(0), ancestorNs.level(1), 
ancestorPhysicalNames)
+                      .stream()
+                      .map(SchemaPO::getSchemaName)
+                      .collect(Collectors.toSet());
+              for (SchemaEntity row : rowsToInsert.subList(0, n - 1)) {
+                if (existingAncestorNames.contains(row.name())) {
+                  continue;
+                }
+                SchemaPO.Builder builder = SchemaPO.builder();
+                fillSchemaPOBuilderParentEntityId(builder, row.namespace());
+                
missingAncestorPOs.add(POConverters.initializeSchemaPOWithVersion(row, 
builder));
+              }
+            }
+            SchemaEntity leafRow = rowsToInsert.get(n - 1);
+            SchemaPO.Builder leafBuilder = SchemaPO.builder();
+            fillSchemaPOBuilderParentEntityId(leafBuilder, 
leafRow.namespace());
+            SchemaPO leafPO = 
POConverters.initializeSchemaPOWithVersion(leafRow, leafBuilder);
+            List<SchemaPO> schemaPosToInsert = new 
ArrayList<>(missingAncestorPOs);
+            schemaPosToInsert.add(leafPO);
             if (overwrite) {
-              mapper.insertSchemaMetaOnDuplicateKeyUpdate(po);
+              
mapper.batchInsertSchemaMetaOnDuplicateKeyUpdate(schemaPosToInsert);
             } else {
-              mapper.insertSchemaMeta(po);
+              mapper.batchInsertSchemaMeta(schemaPosToInsert);
             }
           });
     } catch (RuntimeException re) {
@@ -189,7 +249,6 @@ public class SchemaMetaService {
   public <E extends Entity & HasIdentifier> SchemaEntity updateSchema(
       NameIdentifier identifier, Function<E, E> updater) throws IOException {
     SchemaPO oldSchemaPO = getSchemaPOByIdentifier(identifier);
-
     SchemaEntity oldSchemaEntity = POConverters.fromSchemaPO(oldSchemaPO, 
identifier.namespace());
     SchemaEntity newEntity = (SchemaEntity) updater.apply((E) oldSchemaEntity);
     Preconditions.checkArgument(
@@ -555,4 +614,13 @@ public class SchemaMetaService {
           return POConverters.fromSchemaPOs(schemaPOs, firstIdent.namespace());
         });
   }
+
+  private static long nextIdForNestedAncestor() {
+    IdGenerator generator = GravitinoEnv.getInstance().idGenerator();
+    if (generator == null) {
+      throw new IllegalStateException(
+          "IdGenerator is not initialized in GravitinoEnv; ensure it is set up 
before inserting nested schemas");
+    }
+    return generator.nextId();
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/StatisticMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/StatisticMetaService.java
index 81880859f3..31fd741e39 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/StatisticMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/StatisticMetaService.java
@@ -58,7 +58,9 @@ public class StatisticMetaService {
             mapper ->
                 mapper.listStatisticPOsByEntityId(
                     namespacedEntityId.namespaceIds()[0], 
namespacedEntityId.entityId()));
-    return 
statisticPOs.stream().map(StatisticPO::fromStatisticPO).collect(Collectors.toList());
+    return statisticPOs.stream()
+        .map(po -> StatisticPO.fromStatisticPO(po, identifier))
+        .collect(Collectors.toList());
   }
 
   @Monitored(
diff --git 
a/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java 
b/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java
new file mode 100644
index 0000000000..4d6a3fae91
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/utils/HierarchicalSchemaUtil.java
@@ -0,0 +1,166 @@
+/*
+ * 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.utils;
+
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+
+/**
+ * Utility class for hierarchical schema name conversions.
+ *
+ * <p>Gravitino supports HierarchicalSchema semantics where a logical schema 
name like {@code A:B:C}
+ * (using a configurable external separator, default {@code :}) is mapped to a 
physical schema name
+ * {@code A\u0001B\u0001C} stored in {@code EntityStore} using ASCII-1 ({@code 
\u0001}) as the
+ * internal separator.
+ *
+ * <p>Schema names in {@code EntityStore} never contain the external 
separator; the external
+ * separator is only used at the API boundary and in catalog capability 
validation.
+ */
+public final class HierarchicalSchemaUtil {
+
+  /** The internal separator used in EntityStore for HierarchicalSchema names. 
*/
+  private static final String PHYSICAL_SEPARATOR = "\u0001";
+
+  private HierarchicalSchemaUtil() {}
+
+  /**
+   * Returns the configured external schema separator from the server 
configuration. All code that
+   * needs the separator should use this method instead of reading the config 
directly.
+   *
+   * @return the configured separator string (default {@code ":"})
+   */
+  public static String schemaSeparator() {
+    Config config = GravitinoEnv.getInstance().config();
+    if (config == null) {
+      return Configs.SCHEMA_SEPARATOR.getDefaultValue();
+    }
+    String separator = config.get(Configs.SCHEMA_SEPARATOR);
+    return StringUtils.defaultIfBlank(separator, 
Configs.SCHEMA_SEPARATOR.getDefaultValue());
+  }
+
+  /** Returns the internal physical separator used in EntityStore. */
+  public static String physicalSeparator() {
+    return PHYSICAL_SEPARATOR;
+  }
+
+  /**
+   * Converts a logical schema path (using the external separator) to a 
physical schema name (using
+   * ASCII-1 ({@code \u0001}) as separator) suitable for storage in 
EntityStore.
+   *
+   * <p>Example: {@code "A:B:C"} with separator {@code ":"} → {@code 
"A\u0001B\u0001C"}
+   *
+   * @param logicalPath the logical schema path using the external separator
+   * @param separator the external separator configured on the server
+   * @return the physical schema name using ASCII-1 as separator
+   */
+  public static String logicalToPhysical(String logicalPath, String separator) 
{
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(logicalPath), "logicalPath must not be blank");
+    Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator 
must not be blank");
+    return logicalPath.replace(separator, PHYSICAL_SEPARATOR);
+  }
+
+  /**
+   * Converts a physical schema name (using ASCII-1 as separator) back to the 
logical schema path
+   * using the configured external separator.
+   *
+   * <p>Example: {@code "A\u0001B\u0001C"} with separator {@code ":"} → {@code 
"A:B:C"}
+   *
+   * @param physicalName the physical schema name using ASCII-1 as separator
+   * @param separator the external separator configured on the server
+   * @return the logical schema path using the external separator
+   */
+  public static String physicalToLogical(String physicalName, String 
separator) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(physicalName), "physicalName must not be 
blank");
+    Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator 
must not be blank");
+    return physicalName.replace(PHYSICAL_SEPARATOR, separator);
+  }
+
+  /**
+   * Returns whether a schema name is a hierarchical path (contains the 
external separator).
+   *
+   * @param name the schema name to test
+   * @param separator the external separator
+   * @return {@code true} if the name contains the external separator
+   */
+  public static boolean isHierarchical(String name, String separator) {
+    return StringUtils.isNotBlank(name) && name.contains(separator);
+  }
+
+  /**
+   * Returns all ancestor schema names of the given schema name, ordered from 
outermost to innermost
+   * (but excluding the name itself). Returns an empty list for top-level 
(non-HierarchicalSchema)
+   * schemas.
+   *
+   * <p>Example: {@code "A:B:C"} with separator {@code ":"} → {@code ["A", 
"A:B"]}
+   *
+   * @param schemaName the schema name to find ancestors for
+   * @param separator the external separator
+   * @return ancestor names from outermost to innermost, or empty list if not 
HierarchicalSchema
+   */
+  public static List<String> getAncestorNames(String schemaName, String 
separator) {
+    Preconditions.checkArgument(StringUtils.isNotBlank(schemaName), 
"schemaName must not be blank");
+    Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator 
must not be blank");
+    String[] parts = schemaName.split(Pattern.quote(separator), -1);
+    List<String> ancestors = new ArrayList<>();
+    for (int i = 1; i < parts.length; i++) {
+      ancestors.add(String.join(separator, Arrays.copyOf(parts, i)));
+    }
+    return ancestors;
+  }
+
+  /**
+   * Returns the schema name and all its ancestor schema names, ordered from 
the schema itself to
+   * the outermost ancestor. Returns a single-element list for top-level 
(non-HierarchicalSchema)
+   * schemas.
+   *
+   * <p>Example: {@code "A:B:C"} with separator {@code ":"} → {@code ["A:B:C", 
"A:B", "A"]}
+   *
+   * <p>This is used for privilege inheritance: a privilege on an ancestor 
schema is inherited by
+   * all descendant schemas.
+   *
+   * @param schemaName the schema name to compute scopes for
+   * @param separator the external separator
+   * @return the schema name and all ancestor names, from most specific to 
outermost
+   */
+  public static List<String> allScopes(String schemaName, String separator) {
+    Preconditions.checkArgument(StringUtils.isNotBlank(schemaName), 
"schemaName must not be blank");
+    Preconditions.checkArgument(StringUtils.isNotBlank(separator), "separator 
must not be blank");
+    List<String> result = new ArrayList<>();
+    result.add(schemaName);
+    String current = schemaName;
+    while (current.contains(separator)) {
+      int lastIdx = current.lastIndexOf(separator);
+      current = current.substring(0, lastIdx);
+      if (current.isEmpty()) {
+        break;
+      }
+      result.add(current);
+    }
+    return result;
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
index 03c7c43b5a..a9377c8b59 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/BackendTestExtension.java
@@ -46,6 +46,7 @@ import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.integration.test.util.BaseIT;
+import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.service.EntityIdService;
 import org.junit.jupiter.api.extension.AfterAllCallback;
 import org.junit.jupiter.api.extension.BeforeAllCallback;
@@ -178,6 +179,8 @@ public class BackendTestExtension
       Mockito.when(config.get(CACHE_ENABLED)).thenReturn(true);
 
       FieldUtils.writeField(GravitinoEnv.getInstance(), "config", config, 
true);
+      FieldUtils.writeField(
+          GravitinoEnv.getInstance(), "idGenerator", 
RandomIdGenerator.INSTANCE, true);
 
       RelationalBackend backend = new JDBCBackend();
       if ("mysql".equals(type)) {
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOwnerMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOwnerMetaService.java
index 46de145356..fc57a734ee 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOwnerMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOwnerMetaService.java
@@ -28,6 +28,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
+import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.RelationalEntity;
@@ -735,6 +736,154 @@ class TestOwnerMetaService extends TestJDBCBackend {
     Assertions.assertTrue(relations.isEmpty());
   }
 
+  @TestTemplate
+  void testBatchSetOwnersWithEmptyInput() {
+    OwnerMetaService.getInstance()
+        .batchSetOwners(
+            Collections.emptyList(),
+            Entity.EntityType.TABLE,
+            NameIdentifier.of(METALAKE_NAME, "u"),
+            Entity.EntityType.USER);
+  }
+
+  @TestTemplate
+  void testBatchSetOwnersAssignsSameUserToMultipleTables() throws IOException {
+    createAndInsertMakeLake(METALAKE_NAME);
+    createAndInsertCatalog(METALAKE_NAME, CATALOG_NAME);
+    createAndInsertSchema(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME);
+    TableEntity table1 =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            Namespace.of(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME),
+            TABLE_NAME,
+            AUDIT_INFO);
+    TableEntity table2 =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            Namespace.of(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME),
+            TABLE_NAME + "_2",
+            AUDIT_INFO);
+    backend.insert(table1, false);
+    backend.insert(table2, false);
+
+    UserEntity user =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(METALAKE_NAME),
+            "batch_owner",
+            AUDIT_INFO);
+    backend.insert(user, false);
+
+    OwnerMetaService.getInstance()
+        .batchSetOwners(
+            List.of(table1.nameIdentifier(), table2.nameIdentifier()),
+            Entity.EntityType.TABLE,
+            user.nameIdentifier(),
+            user.type());
+
+    Assertions.assertEquals(
+        user.nameIdentifier(),
+        ((HasIdentifier)
+                OwnerMetaService.getInstance()
+                    .getOwner(table1.nameIdentifier(), table1.type())
+                    .get())
+            .nameIdentifier());
+    Assertions.assertEquals(
+        user.nameIdentifier(),
+        ((HasIdentifier)
+                OwnerMetaService.getInstance()
+                    .getOwner(table2.nameIdentifier(), table2.type())
+                    .get())
+            .nameIdentifier());
+  }
+
+  @TestTemplate
+  void testBatchSetOwnersReplacesPriorOwner() throws IOException {
+    createAndInsertMakeLake(METALAKE_NAME);
+    createAndInsertCatalog(METALAKE_NAME, CATALOG_NAME);
+    createAndInsertSchema(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME);
+    TableEntity table =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            Namespace.of(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME),
+            TABLE_NAME,
+            AUDIT_INFO);
+    backend.insert(table, false);
+
+    UserEntity user1 =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(METALAKE_NAME),
+            "u1",
+            AUDIT_INFO);
+    UserEntity user2 =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(METALAKE_NAME),
+            "u2",
+            AUDIT_INFO);
+    backend.insert(user1, false);
+    backend.insert(user2, false);
+
+    OwnerMetaService.getInstance()
+        .setOwner(table.nameIdentifier(), table.type(), 
user1.nameIdentifier(), user1.type());
+    OwnerMetaService.getInstance()
+        .batchSetOwners(
+            List.of(table.nameIdentifier()),
+            Entity.EntityType.TABLE,
+            user2.nameIdentifier(),
+            user2.type());
+
+    Assertions.assertEquals(
+        user2.nameIdentifier(),
+        ((HasIdentifier)
+                
OwnerMetaService.getInstance().getOwner(table.nameIdentifier(), 
table.type()).get())
+            .nameIdentifier());
+  }
+
+  @TestTemplate
+  void testBatchSetOwnersRequiresSingleMetalake() throws IOException {
+    String ml2 = METALAKE_NAME + "_other";
+    createAndInsertMakeLake(METALAKE_NAME);
+    createAndInsertMakeLake(ml2);
+    createAndInsertCatalog(METALAKE_NAME, CATALOG_NAME);
+    createAndInsertCatalog(ml2, CATALOG_NAME);
+    createAndInsertSchema(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME);
+    createAndInsertSchema(ml2, CATALOG_NAME, SCHEMA_NAME);
+    TableEntity table1 =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            Namespace.of(METALAKE_NAME, CATALOG_NAME, SCHEMA_NAME),
+            TABLE_NAME,
+            AUDIT_INFO);
+    TableEntity table2 =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            Namespace.of(ml2, CATALOG_NAME, SCHEMA_NAME),
+            TABLE_NAME,
+            AUDIT_INFO);
+    backend.insert(table1, false);
+    backend.insert(table2, false);
+
+    UserEntity user =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(METALAKE_NAME),
+            "u",
+            AUDIT_INFO);
+    backend.insert(user, false);
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            OwnerMetaService.getInstance()
+                .batchSetOwners(
+                    List.of(table1.nameIdentifier(), table2.nameIdentifier()),
+                    Entity.EntityType.TABLE,
+                    user.nameIdentifier(),
+                    user.type()));
+  }
+
   @TestTemplate
   void testBatchGetOwnerNoneHaveOwners() throws IOException {
     createAndInsertMakeLake(METALAKE_NAME);
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSchemaMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSchemaMetaService.java
index ad806f28f4..2c19502caa 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSchemaMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSchemaMetaService.java
@@ -24,14 +24,21 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.IOException;
 import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.exceptions.NonEmptyEntityException;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.TopicEntity;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
 import org.junit.jupiter.api.Assertions;
@@ -205,4 +212,106 @@ public class TestSchemaMetaService extends 
TestJDBCBackend {
     topicMetaService.deleteTopic(topic.nameIdentifier());
     schemaMetaService.deleteSchema(schema.nameIdentifier(), false);
   }
+
+  @TestTemplate
+  public void testInsertHierarchicalSchemaCreatesAncestorsAndLeaf() throws 
IOException {
+    createAndInsertMakeLake(metalakeName);
+    createAndInsertCatalog(metalakeName, catalogName);
+
+    SchemaMetaService schemaMetaService = SchemaMetaService.getInstance();
+    String logicalLeaf = "ns_a:ns_b:leaf";
+    String sep = HierarchicalSchemaUtil.schemaSeparator();
+    String physicalLeaf = 
HierarchicalSchemaUtil.logicalToPhysical(logicalLeaf, sep);
+    SchemaEntity hierarchical =
+        SchemaEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName(physicalLeaf)
+            .withNamespace(NamespaceUtil.ofSchema(metalakeName, catalogName))
+            .withComment("nested")
+            .withProperties(Collections.emptyMap())
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    schemaMetaService.insertSchema(hierarchical, false);
+
+    List<SchemaEntity> schemas =
+        
schemaMetaService.listSchemasByNamespace(NamespaceUtil.ofSchema(metalakeName, 
catalogName));
+    Set<String> logicalNames =
+        schemas.stream()
+            .map(SchemaEntity::name)
+            .map(
+                n -> {
+                  if (n != null && 
n.contains(HierarchicalSchemaUtil.physicalSeparator())) {
+                    return HierarchicalSchemaUtil.physicalToLogical(n, sep);
+                  }
+                  return n;
+                })
+            .collect(Collectors.toSet());
+
+    Assertions.assertTrue(logicalNames.contains("ns_a"));
+    Assertions.assertTrue(logicalNames.contains("ns_a:ns_b"));
+    Assertions.assertTrue(logicalNames.contains(logicalLeaf));
+
+    SchemaEntity loaded =
+        schemaMetaService.getSchemaByIdentifier(
+            NameIdentifier.of(metalakeName, catalogName, physicalLeaf));
+    Assertions.assertEquals(physicalLeaf, loaded.name());
+    Assertions.assertEquals("nested", loaded.comment());
+  }
+
+  @TestTemplate
+  public void testInsertHierarchicalSecondLeafReusesAncestorsWithoutUpsert() 
throws IOException {
+    createAndInsertMakeLake(metalakeName);
+    createAndInsertCatalog(metalakeName, catalogName);
+
+    SchemaMetaService schemaMetaService = SchemaMetaService.getInstance();
+    String sep = HierarchicalSchemaUtil.schemaSeparator();
+    String physSep = HierarchicalSchemaUtil.physicalSeparator();
+    String physicalLeaf1 = 
HierarchicalSchemaUtil.logicalToPhysical("ns_a:ns_b:leaf1", sep);
+    String physicalLeaf2 = 
HierarchicalSchemaUtil.logicalToPhysical("ns_a:ns_b:leaf2", sep);
+    String[] parts = physicalLeaf1.split(Pattern.quote(physSep), -1);
+    String ancestorA = parts[0];
+    String ancestorAB = String.join(physSep, Arrays.copyOfRange(parts, 0, 2));
+
+    SchemaEntity first =
+        SchemaEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName(physicalLeaf1)
+            .withNamespace(NamespaceUtil.ofSchema(metalakeName, catalogName))
+            .withComment("first")
+            .withProperties(Collections.emptyMap())
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    schemaMetaService.insertSchema(first, false);
+
+    long idA =
+        schemaMetaService
+            .getSchemaByIdentifier(NameIdentifier.of(metalakeName, 
catalogName, ancestorA))
+            .id();
+    long idAB =
+        schemaMetaService
+            .getSchemaByIdentifier(NameIdentifier.of(metalakeName, 
catalogName, ancestorAB))
+            .id();
+
+    SchemaEntity second =
+        SchemaEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName(physicalLeaf2)
+            .withNamespace(NamespaceUtil.ofSchema(metalakeName, catalogName))
+            .withComment("second")
+            .withProperties(Collections.emptyMap())
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    schemaMetaService.insertSchema(second, false);
+
+    Assertions.assertEquals(
+        idA,
+        schemaMetaService
+            .getSchemaByIdentifier(NameIdentifier.of(metalakeName, 
catalogName, ancestorA))
+            .id());
+    Assertions.assertEquals(
+        idAB,
+        schemaMetaService
+            .getSchemaByIdentifier(NameIdentifier.of(metalakeName, 
catalogName, ancestorAB))
+            .id());
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
index 93e0de7fd1..bff96b2bd6 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
@@ -1317,10 +1317,13 @@ public class TestPOConverters {
                             .withCreateTime(FIX_INSTANT)
                             .build()))
             .build();
-    StatisticEntity entity = StatisticPO.fromStatisticPO(statisticPO);
+    NameIdentifier parentTableIdent =
+        NameIdentifier.of("test_metalake", "test_catalog", "test_schema", 
"test_table");
+    StatisticEntity entity = StatisticPO.fromStatisticPO(statisticPO, 
parentTableIdent);
     Assertions.assertEquals(1L, entity.id());
     Assertions.assertEquals("test", entity.name());
     Assertions.assertEquals("test", entity.value().value());
+    Assertions.assertEquals(Namespace.fromString(parentTableIdent.toString()), 
entity.namespace());
   }
 
   private static BaseMetalake createMetalake(Long id, String name, String 
comment) {
diff --git 
a/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java 
b/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java
new file mode 100644
index 0000000000..e1b65bb66f
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/utils/TestHierarchicalSchemaUtil.java
@@ -0,0 +1,144 @@
+/*
+ * 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.utils;
+
+import java.util.List;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class TestHierarchicalSchemaUtil {
+
+  private static final String PHYS = "\u0001";
+
+  @Test
+  public void testPhysicalSeparator() {
+    Assertions.assertEquals(PHYS, HierarchicalSchemaUtil.physicalSeparator());
+  }
+
+  @Test
+  public void testLogicalToPhysicalAndBack() {
+    String logical = "A:B:C";
+    String sep = ":";
+    String physical = HierarchicalSchemaUtil.logicalToPhysical(logical, sep);
+    Assertions.assertEquals("A" + PHYS + "B" + PHYS + "C", physical);
+    Assertions.assertEquals(logical, 
HierarchicalSchemaUtil.physicalToLogical(physical, sep));
+  }
+
+  @Test
+  public void testLogicalToPhysicalMultiCharSeparator() {
+    String logical = "ns|sub|leaf";
+    String physical = HierarchicalSchemaUtil.logicalToPhysical(logical, "|");
+    Assertions.assertEquals("ns" + PHYS + "sub" + PHYS + "leaf", physical);
+    Assertions.assertEquals(logical, 
HierarchicalSchemaUtil.physicalToLogical(physical, "|"));
+  }
+
+  @Test
+  public void testLogicalToPhysicalSingleSegment() {
+    Assertions.assertEquals("sales", 
HierarchicalSchemaUtil.logicalToPhysical("sales", ":"));
+  }
+
+  @Test
+  public void testLogicalToPhysicalBlankArguments() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
HierarchicalSchemaUtil.logicalToPhysical("", ":"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
HierarchicalSchemaUtil.logicalToPhysical("A", ""));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
HierarchicalSchemaUtil.logicalToPhysical("A", "   "));
+  }
+
+  @Test
+  public void testPhysicalToLogicalBlankArguments() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
HierarchicalSchemaUtil.physicalToLogical("", ":"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> HierarchicalSchemaUtil.physicalToLogical("A" + PHYS + "B", ""));
+  }
+
+  @Test
+  public void testIsHierarchical() {
+    Assertions.assertTrue(HierarchicalSchemaUtil.isHierarchical("A:B", ":"));
+    Assertions.assertFalse(HierarchicalSchemaUtil.isHierarchical("AB", ":"));
+    Assertions.assertFalse(HierarchicalSchemaUtil.isHierarchical("A", ":"));
+    Assertions.assertFalse(HierarchicalSchemaUtil.isHierarchical("", ":"));
+    Assertions.assertFalse(HierarchicalSchemaUtil.isHierarchical("   ", ":"));
+    Assertions.assertFalse(HierarchicalSchemaUtil.isHierarchical(null, ":"));
+  }
+
+  @Test
+  public void testGetAncestorNames() {
+    Assertions.assertEquals(
+        List.of("A", "A:B"), HierarchicalSchemaUtil.getAncestorNames("A:B:C", 
":"));
+    Assertions.assertEquals(List.of(), 
HierarchicalSchemaUtil.getAncestorNames("root", ":"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
HierarchicalSchemaUtil.getAncestorNames("", ":"));
+  }
+
+  @Test
+  public void testAllScopes() {
+    Assertions.assertEquals(
+        List.of("A:B:C", "A:B", "A"), 
HierarchicalSchemaUtil.allScopes("A:B:C", ":"));
+    Assertions.assertEquals(List.of("root"), 
HierarchicalSchemaUtil.allScopes("root", ":"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
HierarchicalSchemaUtil.allScopes("", ":"));
+  }
+
+  @Test
+  public void testSchemaSeparatorWhenConfigNull() throws 
IllegalAccessException {
+    Config saved = GravitinoEnv.getInstance().config();
+    try {
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", null, true);
+      Assertions.assertEquals(":", HierarchicalSchemaUtil.schemaSeparator());
+    } finally {
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", saved, true);
+    }
+  }
+
+  @Test
+  public void testSchemaSeparatorFromConfig() throws IllegalAccessException {
+    Config saved = GravitinoEnv.getInstance().config();
+    Config mockConfig = Mockito.mock(Config.class);
+    Mockito.when(mockConfig.get(Configs.SCHEMA_SEPARATOR)).thenReturn("|");
+    try {
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", mockConfig, 
true);
+      Assertions.assertEquals("|", HierarchicalSchemaUtil.schemaSeparator());
+    } finally {
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", saved, true);
+    }
+  }
+
+  @Test
+  public void testSchemaSeparatorBlankFallsBackToDefault() throws 
IllegalAccessException {
+    Config saved = GravitinoEnv.getInstance().config();
+    Config mockConfig = Mockito.mock(Config.class);
+    Mockito.when(mockConfig.get(Configs.SCHEMA_SEPARATOR)).thenReturn("  ");
+    try {
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", mockConfig, 
true);
+      Assertions.assertEquals(":", HierarchicalSchemaUtil.schemaSeparator());
+    } finally {
+      FieldUtils.writeField(GravitinoEnv.getInstance(), "config", saved, true);
+    }
+  }
+}

Reply via email to