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

mchades 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 42ba56320c [#12615] feat(tag): Add tag support for Semantic Models 
(#12870)
42ba56320c is described below

commit 42ba56320c713ce9c1c0cc8ee4f8f1c4045f04dd
Author: Akshay Thorat <[email protected]>
AuthorDate: Fri Oct 2 19:27:11 2026 -0700

    [#12615] feat(tag): Add tag support for Semantic Models (#12870)
    
    ### What changes were proposed in this pull request?
    
    Register `SEMANTIC_MODEL` as a supported tag target and document it in
    the tag guide and OpenAPI specification. Add tests for existence
    validation, schema/catalog tag inheritance, REST list/get/associate
    operations, and orphan relation cleanup.
    
    This uses the Semantic Model ID resolution, full-name resolution,
    internal existence checks, and orphan-cleanup registration now present
    on main. Direct policy associations and UI changes are out of scope.
    
    ### Why are the changes needed?
    
    Semantic Models need the existing tag governance APIs, including
    policies applied through tags.
    
    Fix: #12615
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Semantic Models are accepted by the metadata-object tag APIs and
    inherit tags from their parent schema and catalog.
    
    ### How was this patch tested?
    
    - Core and server unit tests, plus `TestMetadataAuthzHelper` regression
    tests.
    - Java formatting checks for core and server.
    - `./gradlew :docs:build` validates the OpenAPI specification.
    
    Docker integration tests were not run.
---
 .../java/org/apache/gravitino/tag/TagManager.java  |  3 +-
 .../TestOrphanedMetadataObjectRelationService.java | 59 +++++++++++++++
 .../org/apache/gravitino/tag/TestTagManager.java   | 49 +++++++++++++
 .../gravitino/utils/TestMetadataObjectUtil.java    |  8 ++
 docs/open-api/openapi.yaml                         |  1 +
 docs/open-api/tags.yaml                            |  3 +-
 docs/tags.md                                       | 27 +++----
 .../web/rest/TestMetadataObjectTagOperations.java  | 85 ++++++++++++++++++++++
 8 files changed, 220 insertions(+), 15 deletions(-)

diff --git a/core/src/main/java/org/apache/gravitino/tag/TagManager.java 
b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
index 69bc1c4d62..540db3748f 100644
--- a/core/src/main/java/org/apache/gravitino/tag/TagManager.java
+++ b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
@@ -84,7 +84,8 @@ public class TagManager implements TagDispatcher {
           MetadataObject.Type.TOPIC,
           MetadataObject.Type.COLUMN,
           MetadataObject.Type.MODEL,
-          MetadataObject.Type.FUNCTION);
+          MetadataObject.Type.FUNCTION,
+          MetadataObject.Type.SEMANTIC_MODEL);
 
   public TagManager(IdGenerator idGenerator, EntityStore entityStore) {
     this.idGenerator = idGenerator;
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOrphanedMetadataObjectRelationService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOrphanedMetadataObjectRelationService.java
index c89b4b162f..807cadf2a0 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOrphanedMetadataObjectRelationService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOrphanedMetadataObjectRelationService.java
@@ -25,7 +25,10 @@ import java.sql.SQLException;
 import java.sql.Statement;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.ColumnEntity;
+import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.rel.types.Types;
 import org.apache.gravitino.storage.RandomIdGenerator;
@@ -108,6 +111,38 @@ public class TestOrphanedMetadataObjectRelationService 
extends TestJDBCBackend {
     Assertions.assertEquals(0, countActiveRelations(droppedColumn.id()));
   }
 
+  @TestTemplate
+  public void testSoftDeleteOrphanedSemanticModelRelations() throws Exception {
+    String metalake = "metalake_for_semantic_model_orphan_test";
+    String catalog = "catalog_for_semantic_model_orphan_test";
+    String schema = "schema_for_semantic_model_orphan_test";
+    BaseMetalake metalakeEntity = createAndInsertMakeLake(metalake);
+    CatalogEntity catalogEntity = createAndInsertCatalog(metalake, catalog);
+    SchemaEntity schemaEntity = createAndInsertSchema(metalake, catalog, 
schema);
+
+    // Insert the identity row directly to isolate relation cleanup from 
lifecycle operations.
+    long liveSemanticModelId = 123456789L;
+    insertSemanticModel(
+        liveSemanticModelId,
+        "live_semantic_model",
+        metalakeEntity.id(),
+        catalogEntity.id(),
+        schemaEntity.id());
+
+    insertRelations("SEMANTIC_MODEL", liveSemanticModelId, ORPHAN_ID);
+
+    Assertions.assertEquals(
+        4,
+        OrphanedMetadataObjectRelationService.getInstance()
+            .softDeleteOrphanedRelations(MetadataObject.Type.SEMANTIC_MODEL, 
10));
+    Assertions.assertEquals(4, countActiveRelations(liveSemanticModelId));
+    Assertions.assertEquals(0, countActiveRelations(ORPHAN_ID));
+    Assertions.assertEquals(
+        0,
+        OrphanedMetadataObjectRelationService.getInstance()
+            .softDeleteOrphanedRelations(MetadataObject.Type.SEMANTIC_MODEL, 
10));
+  }
+
   private static ColumnEntity column(long id, String name, int position) {
     return ColumnEntity.builder()
         .withId(id)
@@ -120,6 +155,30 @@ public class TestOrphanedMetadataObjectRelationService 
extends TestJDBCBackend {
         .build();
   }
 
+  private void insertSemanticModel(
+      long semanticModelId, String name, long metalakeId, long catalogId, long 
schemaId)
+      throws SQLException {
+    try (SqlSession session =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = session.getConnection();
+        Statement statement = connection.createStatement()) {
+      statement.executeUpdate(
+          "INSERT INTO semantic_model_meta (semantic_model_id, 
semantic_model_name, metalake_id,"
+              + " catalog_id, schema_id, audit_info, current_version, 
last_version, deleted_at)"
+              + " VALUES ("
+              + semanticModelId
+              + ", '"
+              + name
+              + "', "
+              + metalakeId
+              + ", "
+              + catalogId
+              + ", "
+              + schemaId
+              + ", '{}', 1, 1, 0)");
+    }
+  }
+
   private void insertRelations(String type, long liveId, long orphanId) throws 
SQLException {
     try (SqlSession session =
             
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
diff --git a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java 
b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
index a0fb0d3a76..74156b1260 100644
--- a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
+++ b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
@@ -67,8 +67,10 @@ import org.apache.gravitino.RelationalEntity;
 import org.apache.gravitino.catalog.CatalogDispatcher;
 import org.apache.gravitino.catalog.FunctionDispatcher;
 import org.apache.gravitino.catalog.SchemaDispatcher;
+import org.apache.gravitino.catalog.SemanticModelDispatcher;
 import org.apache.gravitino.catalog.TableDispatcher;
 import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.NotFoundException;
@@ -135,12 +137,16 @@ public class TestTagManager {
 
   private static final String FUNCTION = "function_for_tag_test";
 
+  private static final String SEMANTIC_MODEL = "semantic_model_for_tag_test";
+
   private static final MetalakeDispatcher metalakeDispatcher = 
mock(MetalakeDispatcher.class);
   private static final CatalogDispatcher catalogDispatcher = 
mock(CatalogDispatcher.class);
   private static final SchemaDispatcher schemaDispatcher = 
mock(SchemaDispatcher.class);
   private static final TableDispatcher tableDispatcher = 
mock(TableDispatcher.class);
   private static final ViewDispatcher viewDispatcher = 
mock(ViewDispatcher.class);
   private static final FunctionDispatcher functionDispatcher = 
mock(FunctionDispatcher.class);
+  private static final SemanticModelDispatcher semanticModelDispatcher =
+      mock(SemanticModelDispatcher.class);
 
   private static EntityStore entityStore;
 
@@ -286,6 +292,11 @@ public class TestTagManager {
         GravitinoEnv.getInstance(), "internalViewDispatcher", viewDispatcher, 
true);
     FieldUtils.writeField(
         GravitinoEnv.getInstance(), "internalFunctionDispatcher", 
functionDispatcher, true);
+    FieldUtils.writeField(
+        GravitinoEnv.getInstance(),
+        "internalSemanticModelDispatcher",
+        semanticModelDispatcher,
+        true);
 
     when(metalakeDispatcher.metalakeExists(any())).thenReturn(true);
     when(catalogDispatcher.catalogExists(any())).thenReturn(true);
@@ -1142,6 +1153,44 @@ public class TestTagManager {
         e3.getMessage().contains("Failed to get tag for metadata object " + 
nonExistentObject));
   }
 
+  @Test
+  public void testSemanticModelIsSupportedForTags() {
+    Tag tag1 = tagManager.createTag(METALAKE, "tag1", null, null);
+
+    MetadataObject semanticModelObject =
+        NameIdentifierUtil.toMetadataObject(
+            NameIdentifierUtil.ofSemanticModel(METALAKE, CATALOG, SCHEMA, 
SEMANTIC_MODEL),
+            Entity.EntityType.SEMANTIC_MODEL);
+    Assertions.assertEquals(MetadataObject.Type.SEMANTIC_MODEL, 
semanticModelObject.type());
+    Assertions.assertEquals(
+        CATALOG + "." + SCHEMA + "." + SEMANTIC_MODEL, 
semanticModelObject.fullName());
+
+    // A Semantic Model is an accepted tag target, so an absent one must fail 
existence validation
+    // rather than be rejected as an unsupported metadata object type.
+    when(semanticModelDispatcher.semanticModelExists(any())).thenReturn(false);
+
+    Throwable e =
+        Assertions.assertThrows(
+            NoSuchMetadataObjectException.class,
+            () ->
+                tagManager.associateTagsForMetadataObject(
+                    METALAKE, semanticModelObject, new String[] {tag1.name()}, 
null));
+    Assertions.assertTrue(
+        e.getMessage()
+            .contains(
+                "Metadata object "
+                    + semanticModelObject.fullName()
+                    + " type SEMANTIC_MODEL doesn't exist"),
+        e.getMessage());
+
+    Assertions.assertThrows(
+        NoSuchMetadataObjectException.class,
+        () -> tagManager.listTagsForMetadataObject(METALAKE, 
semanticModelObject));
+    Assertions.assertThrows(
+        NoSuchMetadataObjectException.class,
+        () -> tagManager.getTagForMetadataObject(METALAKE, 
semanticModelObject, tag1.name()));
+  }
+
   @Test
   public void testPolicyAssociationsForTag() throws IOException {
     String tagName = "policy_tag";
diff --git 
a/core/src/test/java/org/apache/gravitino/utils/TestMetadataObjectUtil.java 
b/core/src/test/java/org/apache/gravitino/utils/TestMetadataObjectUtil.java
index b5bda754fa..4cd8916e04 100644
--- a/core/src/test/java/org/apache/gravitino/utils/TestMetadataObjectUtil.java
+++ b/core/src/test/java/org/apache/gravitino/utils/TestMetadataObjectUtil.java
@@ -210,6 +210,14 @@ public class TestMetadataObjectUtil {
         List.of("CATALOG:catalog"),
         describe(MetadataObjectUtil.getParentMetadataObjects(schema, ":")));
 
+    // A semantic model under a flat schema inherits from [schema, catalog], 
so tags and policies
+    // assigned to its schema or catalog reach it.
+    MetadataObject semanticModel =
+        MetadataObjects.of("catalog.schema", "sales_model", 
MetadataObject.Type.SEMANTIC_MODEL);
+    Assertions.assertEquals(
+        List.of("SCHEMA:catalog.schema", "CATALOG:catalog"),
+        describe(MetadataObjectUtil.getParentMetadataObjects(semanticModel, 
":")));
+
     // A catalog has no ancestors.
     MetadataObject catalog = MetadataObjects.of(null, "catalog", 
MetadataObject.Type.CATALOG);
     Assertions.assertTrue(MetadataObjectUtil.getParentMetadataObjects(catalog, 
":").isEmpty());
diff --git a/docs/open-api/openapi.yaml b/docs/open-api/openapi.yaml
index 37109e3f89..59bfdebe26 100644
--- a/docs/open-api/openapi.yaml
+++ b/docs/open-api/openapi.yaml
@@ -637,6 +637,7 @@ components:
           - "SCHEMA"
           - "TABLE"
           - "VIEW"
+          - "SEMANTIC_MODEL"
           - "COLUMN"
           - "FILESET"
           - "TOPIC"
diff --git a/docs/open-api/tags.yaml b/docs/open-api/tags.yaml
index 37c0cceec9..33fa7dd56a 100644
--- a/docs/open-api/tags.yaml
+++ b/docs/open-api/tags.yaml
@@ -222,7 +222,7 @@ paths:
       tags:
         - tag
       summary: Associate tags with metadata object
-      description: Associate and disassociate tags with metadata object, 
please be aware that supported metadata objects are CATALOG, SCHEMA, TABLE, 
VIEW, FILESET, TOPIC, COLUMN, MODEL, FUNCTION
+      description: Associate and disassociate tags with metadata object, 
please be aware that supported metadata objects are CATALOG, SCHEMA, TABLE, 
VIEW, SEMANTIC_MODEL, FILESET, TOPIC, COLUMN, MODEL, FUNCTION
       operationId: associateTags
       requestBody:
         content:
@@ -546,6 +546,7 @@ components:
             - "SCHEMA"
             - "TABLE"
             - "VIEW"
+            - "SEMANTIC_MODEL"
             - "FILESET"
             - "TOPIC"
             - "MODEL"
diff --git a/docs/tags.md b/docs/tags.md
index f978c2dc9e..f35a3d5cc5 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -49,19 +49,20 @@ attached to directly.
 ### What Can Carry a Tag
 
 A metadata object is identified by a type and a name, with each level below 
the catalog separated by
-a dot. Nine object types can carry a tag.
-
-| Object type | Name form                                                 |
-|-------------|-----------------------------------------------------------|
-| `CATALOG`   | `{catalog_name}`                                          |
-| `SCHEMA`    | `{catalog_name}.{schema_name}`                            |
-| `TABLE`     | `{catalog_name}.{schema_name}.{table_name}`               |
-| `VIEW`      | `{catalog_name}.{schema_name}.{view_name}`                |
-| `COLUMN`    | `{catalog_name}.{schema_name}.{table_name}.{column_name}` |
-| `FILESET`   | `{catalog_name}.{schema_name}.{fileset_name}`             |
-| `TOPIC`     | `{catalog_name}.{schema_name}.{topic_name}`               |
-| `MODEL`     | `{catalog_name}.{schema_name}.{model_name}`               |
-| `FUNCTION`  | `{catalog_name}.{schema_name}.{function_name}`            |
+a dot. Ten object types can carry a tag.
+
+| Object type      | Name form                                                 
|
+|------------------|-----------------------------------------------------------|
+| `CATALOG`        | `{catalog_name}`                                          
|
+| `SCHEMA`         | `{catalog_name}.{schema_name}`                            
|
+| `TABLE`          | `{catalog_name}.{schema_name}.{table_name}`               
|
+| `VIEW`           | `{catalog_name}.{schema_name}.{view_name}`                
|
+| `SEMANTIC_MODEL` | `{catalog_name}.{schema_name}.{semantic_model_name}`      
|
+| `COLUMN`         | `{catalog_name}.{schema_name}.{table_name}.{column_name}` 
|
+| `FILESET`        | `{catalog_name}.{schema_name}.{fileset_name}`             
|
+| `TOPIC`          | `{catalog_name}.{schema_name}.{topic_name}`               
|
+| `MODEL`          | `{catalog_name}.{schema_name}.{model_name}`               
|
+| `FUNCTION`       | `{catalog_name}.{schema_name}.{function_name}`            
|
 
 A metalake cannot carry a tag, so there is no single attachment point that 
covers everything at
 once. To reach every object in a catalog, attach the tag to the catalog.
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectTagOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectTagOperations.java
index 82a38c2849..02f9853063 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectTagOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectTagOperations.java
@@ -1197,6 +1197,91 @@ public class TestMetadataObjectTagOperations extends 
BaseOperationsTest {
     Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
v2WithV1Shape.getStatus());
   }
 
+  @Test
+  public void testTagsForSemanticModel() {
+    MetadataObject catalog = MetadataObjects.parse("object1", 
MetadataObject.Type.CATALOG);
+    MetadataObject schema = MetadataObjects.parse("object1.object2", 
MetadataObject.Type.SCHEMA);
+    MetadataObject semanticModel =
+        MetadataObjects.parse("object1.object2.sales", 
MetadataObject.Type.SEMANTIC_MODEL);
+
+    when(tagManager.listTagsInfoForMetadataObject(metalake, catalog))
+        .thenReturn(tagInfos("catalogTag"));
+    when(tagManager.listTagsInfoForMetadataObject(metalake, schema))
+        .thenReturn(tagInfos("schemaTag"));
+    when(tagManager.listTagsInfoForMetadataObject(metalake, semanticModel))
+        .thenReturn(tagInfos("semanticModelTag"));
+
+    // A Semantic Model shows its own tags plus the tags inherited from its 
schema and catalog.
+    Response listResponse =
+        target(basePath(metalake))
+            .path(semanticModel.type().toString())
+            .path(semanticModel.fullName())
+            .path("tags")
+            .queryParam("details", true)
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
listResponse.getStatus());
+    TagListResponse tagListResponse = 
listResponse.readEntity(TagListResponse.class);
+    Assertions.assertEquals(0, tagListResponse.getCode());
+    Assertions.assertEquals(3, tagListResponse.getTags().length);
+
+    Map<String, Tag> resultTags =
+        Arrays.stream(tagListResponse.getTags())
+            .collect(Collectors.toMap(Tag::name, Function.identity()));
+    
Assertions.assertFalse(resultTags.get("semanticModelTag").inherited().get());
+    Assertions.assertTrue(resultTags.get("schemaTag").inherited().get());
+    Assertions.assertTrue(resultTags.get("catalogTag").inherited().get());
+
+    // Getting a single tag on a Semantic Model.
+    TagEntity semanticModelTag =
+        TagEntity.builder()
+            .withName("semanticModelTag")
+            .withId(1L)
+            .withAuditInfo(testAuditInfo1)
+            .build();
+    when(tagManager.getTagForMetadataObject(metalake, semanticModel, 
"semanticModelTag"))
+        .thenReturn(semanticModelTag);
+
+    Response getResponse =
+        target(basePath(metalake))
+            .path(semanticModel.type().toString())
+            .path(semanticModel.fullName())
+            .path("tags")
+            .path("semanticModelTag")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
getResponse.getStatus());
+    Tag respTag = getResponse.readEntity(TagResponse.class).getTag();
+    Assertions.assertEquals("semanticModelTag", respTag.name());
+    Assertions.assertFalse(respTag.inherited().get());
+
+    // Associating and disassociating tags on a Semantic Model.
+    String[] tagsToAdd = new String[] {"semanticModelTag"};
+    String[] tagsToRemove = new String[] {"staleTag"};
+    when(tagManager.associateTagsForMetadataObject(
+            metalake, semanticModel, tagsToAdd, tagsToRemove))
+        .thenReturn(tagsToAdd);
+
+    TagsAssociateRequest request = new TagsAssociateRequest(tagsToAdd, 
tagsToRemove);
+    Response associateResponse =
+        target(basePath(metalake))
+            .path(semanticModel.type().toString())
+            .path(semanticModel.fullName())
+            .path("tags")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
associateResponse.getStatus());
+    NameListResponse nameListResponse = 
associateResponse.readEntity(NameListResponse.class);
+    Assertions.assertEquals(0, nameListResponse.getCode());
+    Assertions.assertArrayEquals(tagsToAdd, nameListResponse.getNames());
+  }
+
   private String basePath(String metalake) {
     return "/metalakes/" + metalake + "/objects";
   }

Reply via email to