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";
}