This is an automated email from the ASF dual-hosted git repository.
jerryshao 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 c22eb34657 [#12586] feat(core): Add Semantic Model metadata entity
(#12587)
c22eb34657 is described below
commit c22eb346570bdf7411a931def82651f9ceca941e
Author: mchades <[email protected]>
AuthorDate: Wed Aug 26 16:56:24 2026 +0800
[#12586] feat(core): Add Semantic Model metadata entity (#12587)
### What changes were proposed in this pull request?
Add the core metadata entity for first-class Semantic Models:
- Add `Entity.EntityType.SEMANTIC_MODEL`.
- Add `SemanticModelEntity` with an immutable definition, properties,
and audit information.
- Add Semantic Model support to identifier, namespace, and
metadata-object utilities.
- Add focused entity and utility tests, including validation and
defensive-copy behavior.
### Why are the changes needed?
The entity provides the core representation required by relational
persistence and managed lifecycle operation follow-ups while keeping
those concerns in independently reviewable changes.
Fix: #12586
### Does this PR introduce _any_ user-facing change?
No. This PR adds an internal core metadata entity and related utility
support only.
### How was this patch tested?
- `git diff --check`
- `./gradlew :core:spotlessCheck :core:javadoc :core:test --tests
org.apache.gravitino.meta.TestSemanticModelEntity --tests
org.apache.gravitino.utils.TestMetadataObjectUtil --tests
org.apache.gravitino.utils.TestNameIdentifierUtil --tests
org.apache.gravitino.utils.TestNamespaceUtil -PskipITs`
---------
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.../src/main/java/org/apache/gravitino/Entity.java | 1 +
.../apache/gravitino/meta/SemanticModelEntity.java | 337 +++++++++++++++++++++
.../gravitino/storage/relational/JDBCBackend.java | 7 +
.../apache/gravitino/utils/MetadataObjectUtil.java | 2 +
.../apache/gravitino/utils/NameIdentifierUtil.java | 33 ++
.../org/apache/gravitino/utils/NamespaceUtil.java | 25 ++
.../gravitino/meta/TestSemanticModelEntity.java | 304 +++++++++++++++++++
.../gravitino/utils/TestMetadataObjectUtil.java | 13 +
.../gravitino/utils/TestNameIdentifierUtil.java | 23 ++
.../apache/gravitino/utils/TestNamespaceUtil.java | 11 +
10 files changed, 756 insertions(+)
diff --git a/core/src/main/java/org/apache/gravitino/Entity.java
b/core/src/main/java/org/apache/gravitino/Entity.java
index 5c214cb434..43ee549581 100644
--- a/core/src/main/java/org/apache/gravitino/Entity.java
+++ b/core/src/main/java/org/apache/gravitino/Entity.java
@@ -110,6 +110,7 @@ public interface Entity extends Serializable {
SCHEMA,
TABLE,
VIEW,
+ SEMANTIC_MODEL,
COLUMN,
FILESET,
TOPIC,
diff --git
a/core/src/main/java/org/apache/gravitino/meta/SemanticModelEntity.java
b/core/src/main/java/org/apache/gravitino/meta/SemanticModelEntity.java
new file mode 100644
index 0000000000..1f4684d671
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/meta/SemanticModelEntity.java
@@ -0,0 +1,337 @@
+/*
+ * 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.meta;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import lombok.ToString;
+import org.apache.gravitino.Auditable;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.Field;
+import org.apache.gravitino.HasIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.semantic.SemanticModel;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+
+/** A metadata-store entity representing a schema-scoped Semantic Model. */
+@ToString
+public class SemanticModelEntity implements Entity, Auditable, HasIdentifier,
SemanticModel {
+
+ /** The unique ID field of the Semantic Model entity. */
+ public static final Field ID =
+ Field.required("id", Long.class, "The unique id of the Semantic Model
entity.");
+
+ /** The name field of the Semantic Model entity. */
+ public static final Field NAME =
+ Field.required("name", String.class, "The name of the Semantic Model
entity.");
+
+ /** The namespace field of the Semantic Model entity. */
+ public static final Field NAMESPACE =
+ Field.required("namespace", Namespace.class, "The namespace of the
Semantic Model entity.");
+
+ /** The optional comment field of the Semantic Model entity. */
+ public static final Field COMMENT =
+ Field.optional(
+ "comment", String.class, "The comment or description of the Semantic
Model entity.");
+
+ /** The immutable definition field of the Semantic Model entity. */
+ public static final Field DEFINITION =
+ Field.required(
+ "definition",
+ SemanticModelDefinition.class,
+ "The immutable definition of the Semantic Model entity.");
+
+ /** The properties field of the Semantic Model entity. */
+ public static final Field PROPERTIES =
+ Field.optional("properties", Map.class, "The properties of the Semantic
Model entity.");
+
+ /** The audit information field of the Semantic Model entity. */
+ public static final Field AUDIT_INFO =
+ Field.required(
+ "audit_info", AuditInfo.class, "The audit details of the Semantic
Model entity.");
+
+ private Long id;
+ private String name;
+ private Namespace namespace;
+ private SemanticModelDefinition definition;
+ private Map<String, String> properties = Collections.emptyMap();
+ private AuditInfo auditInfo;
+
+ @Nullable private String comment;
+
+ private SemanticModelEntity() {}
+
+ /**
+ * Returns the fields and values of this Semantic Model entity.
+ *
+ * <p>The definition is immutable and the properties map is immutable, so
the returned values
+ * cannot mutate this entity.
+ *
+ * @return An unmodifiable map of fields and values.
+ */
+ @Override
+ public Map<Field, Object> fields() {
+ Map<Field, Object> fields = Maps.newHashMap();
+ fields.put(ID, id);
+ fields.put(NAME, name);
+ fields.put(NAMESPACE, namespace);
+ fields.put(COMMENT, comment);
+ fields.put(DEFINITION, definition);
+ fields.put(PROPERTIES, properties);
+ fields.put(AUDIT_INFO, auditInfo);
+ return Collections.unmodifiableMap(fields);
+ }
+
+ /**
+ * Returns the Semantic Model name.
+ *
+ * @return The Semantic Model name.
+ */
+ @Override
+ public String name() {
+ return name;
+ }
+
+ /**
+ * Returns the unique ID of the Semantic Model entity.
+ *
+ * @return The unique ID.
+ */
+ @Override
+ public Long id() {
+ return id;
+ }
+
+ /**
+ * Returns the namespace of the Semantic Model entity.
+ *
+ * @return The namespace.
+ */
+ @Override
+ public Namespace namespace() {
+ return namespace;
+ }
+
+ /**
+ * Returns the Semantic Model comment.
+ *
+ * @return The comment, or {@code null} if it is not set.
+ */
+ @Nullable
+ @Override
+ public String comment() {
+ return comment;
+ }
+
+ /**
+ * Returns the immutable Semantic Model definition.
+ *
+ * @return The Semantic Model definition.
+ */
+ @Override
+ public SemanticModelDefinition definition() {
+ return definition;
+ }
+
+ /**
+ * Returns the immutable Gravitino-specific properties of the Semantic Model.
+ *
+ * @return The properties, or an empty map if none are set.
+ */
+ @Override
+ public Map<String, String> properties() {
+ return properties;
+ }
+
+ /**
+ * Returns the audit information of the Semantic Model entity.
+ *
+ * @return The audit information.
+ */
+ @Override
+ public AuditInfo auditInfo() {
+ return auditInfo;
+ }
+
+ /**
+ * Returns the Semantic Model entity type.
+ *
+ * @return {@link EntityType#SEMANTIC_MODEL}.
+ */
+ @Override
+ public EntityType type() {
+ return EntityType.SEMANTIC_MODEL;
+ }
+
+ /**
+ * Validates all declared entity fields.
+ *
+ * @throws IllegalArgumentException If a required field is missing or has
the wrong type.
+ */
+ @Override
+ public void validate() throws IllegalArgumentException {
+ Entity.super.validate();
+ }
+
+ /**
+ * Compares this entity with another object for value equality.
+ *
+ * @param other The object to compare with.
+ * @return {@code true} if the objects are equal, otherwise {@code false}.
+ */
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof SemanticModelEntity)) {
+ return false;
+ }
+ SemanticModelEntity that = (SemanticModelEntity) other;
+ return Objects.equals(id, that.id)
+ && Objects.equals(name, that.name)
+ && Objects.equals(namespace, that.namespace)
+ && Objects.equals(comment, that.comment)
+ && Objects.equals(definition, that.definition)
+ && Objects.equals(properties, that.properties)
+ && Objects.equals(auditInfo, that.auditInfo);
+ }
+
+ /**
+ * Returns the value-based hash code of this entity.
+ *
+ * @return The hash code.
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, namespace, comment, definition, properties,
auditInfo);
+ }
+
+ /**
+ * Creates a builder for a Semantic Model entity.
+ *
+ * @return A new builder.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /** A builder for {@link SemanticModelEntity}. */
+ public static class Builder {
+
+ private final SemanticModelEntity semanticModel;
+
+ private Builder() {
+ semanticModel = new SemanticModelEntity();
+ }
+
+ /**
+ * Sets the unique ID of the Semantic Model entity.
+ *
+ * @param id The unique ID.
+ * @return This builder.
+ */
+ public Builder withId(Long id) {
+ semanticModel.id = id;
+ return this;
+ }
+
+ /**
+ * Sets the name of the Semantic Model entity.
+ *
+ * @param name The Semantic Model name.
+ * @return This builder.
+ */
+ public Builder withName(String name) {
+ semanticModel.name = name;
+ return this;
+ }
+
+ /**
+ * Sets the namespace of the Semantic Model entity.
+ *
+ * @param namespace The namespace.
+ * @return This builder.
+ */
+ public Builder withNamespace(Namespace namespace) {
+ semanticModel.namespace = namespace;
+ return this;
+ }
+
+ /**
+ * Sets the optional Semantic Model comment.
+ *
+ * @param comment The comment, or {@code null} to leave it unset.
+ * @return This builder.
+ */
+ public Builder withComment(@Nullable String comment) {
+ semanticModel.comment = comment;
+ return this;
+ }
+
+ /**
+ * Sets the immutable Semantic Model definition.
+ *
+ * @param definition The Semantic Model definition.
+ * @return This builder.
+ */
+ public Builder withDefinition(SemanticModelDefinition definition) {
+ semanticModel.definition = definition;
+ return this;
+ }
+
+ /**
+ * Sets the Gravitino-specific properties.
+ *
+ * @param properties The properties, or {@code null} for no properties.
+ * @return This builder.
+ */
+ public Builder withProperties(@Nullable Map<String, String> properties) {
+ semanticModel.properties =
+ properties == null ? Collections.emptyMap() :
ImmutableMap.copyOf(properties);
+ return this;
+ }
+
+ /**
+ * Sets the audit information of the Semantic Model entity.
+ *
+ * @param auditInfo The audit information.
+ * @return This builder.
+ */
+ public Builder withAuditInfo(AuditInfo auditInfo) {
+ semanticModel.auditInfo = auditInfo;
+ return this;
+ }
+
+ /**
+ * Builds and validates the Semantic Model entity.
+ *
+ * @return The built Semantic Model entity.
+ * @throws IllegalArgumentException If a required field is missing or has
the wrong type.
+ */
+ public SemanticModelEntity build() {
+ semanticModel.validate();
+ return semanticModel;
+ }
+ }
+}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
index 70a0cbdf8f..6661d0014d 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
@@ -539,6 +539,9 @@ public class JDBCBackend implements RelationalBackend,
SupportsOrphanedRelationC
return ViewMetaService.getInstance()
.deleteViewMetasByLegacyTimeline(
legacyTimeline, GARBAGE_COLLECTOR_SINGLE_DELETION_LIMIT);
+ case SEMANTIC_MODEL:
+ // TODO(#12209): Delegate to SemanticModelMetaService when relational
persistence is added.
+ return 0;
case AUDIT:
return 0;
// TODO: Implement hard delete logic for these entity types.
@@ -579,6 +582,10 @@ public class JDBCBackend implements RelationalBackend,
SupportsOrphanedRelationC
// These entity types have not implemented multi-versions, so we can
skip.
return 0;
+ case SEMANTIC_MODEL:
+ // TODO: Delegate to SemanticModelMetaService when relational
persistence is added.
+ return 0;
+
case FILESET:
return FilesetMetaService.getInstance()
.deleteFilesetVersionsByRetentionCount(
diff --git
a/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java
b/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java
index b14845b669..17a0ebb53d 100644
--- a/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java
+++ b/core/src/main/java/org/apache/gravitino/utils/MetadataObjectUtil.java
@@ -63,6 +63,7 @@ public class MetadataObjectUtil {
.put(MetadataObject.Type.JOB_TEMPLATE,
Entity.EntityType.JOB_TEMPLATE)
.put(MetadataObject.Type.JOB, Entity.EntityType.JOB)
.put(MetadataObject.Type.VIEW, Entity.EntityType.VIEW)
+ .put(MetadataObject.Type.SEMANTIC_MODEL,
Entity.EntityType.SEMANTIC_MODEL)
.put(MetadataObject.Type.FUNCTION, Entity.EntityType.FUNCTION)
.build();
@@ -126,6 +127,7 @@ public class MetadataObjectUtil {
case JOB_TEMPLATE:
return NameIdentifierUtil.ofJobTemplate(metalakeName,
metadataObject.name());
case VIEW:
+ case SEMANTIC_MODEL:
case CATALOG:
case SCHEMA:
case TABLE:
diff --git
a/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java
b/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java
index b7bc9b742d..19b664adad 100644
--- a/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java
+++ b/core/src/main/java/org/apache/gravitino/utils/NameIdentifierUtil.java
@@ -120,6 +120,20 @@ public class NameIdentifierUtil {
return NameIdentifier.of(metalake, catalog, schema, view);
}
+ /**
+ * Create the semantic model {@link NameIdentifier} with the given parent
names.
+ *
+ * @param metalake The metalake name
+ * @param catalog The catalog name
+ * @param schema The schema name
+ * @param semanticModel The semantic model name
+ * @return The created semantic model {@link NameIdentifier}
+ */
+ public static NameIdentifier ofSemanticModel(
+ String metalake, String catalog, String schema, String semanticModel) {
+ return NameIdentifier.of(metalake, catalog, schema, semanticModel);
+ }
+
/**
* Create the tag {@link NameIdentifier} with the given metalake and tag
name.
*
@@ -505,6 +519,17 @@ public class NameIdentifierUtil {
NamespaceUtil.checkView(ident.namespace());
}
+ /**
+ * Check the given {@link NameIdentifier} is a semantic model identifier.
Throw an {@link
+ * IllegalNameIdentifierException} if it's not.
+ *
+ * @param ident The semantic model {@link NameIdentifier} to check.
+ */
+ public static void checkSemanticModel(NameIdentifier ident) {
+ NameIdentifier.check(ident != null, "Semantic model identifier must not be
null");
+ NamespaceUtil.checkSemanticModel(ident.namespace());
+ }
+
/**
* Check the given {@link NameIdentifier} is a column identifier. Throw an
{@link
* IllegalNameIdentifierException} if it's not.
@@ -632,6 +657,13 @@ public class NameIdentifierUtil {
String viewParent = dot.join(ident.namespace().level(1),
ident.namespace().level(2));
return MetadataObjects.of(viewParent, ident.name(),
MetadataObject.Type.VIEW);
+ case SEMANTIC_MODEL:
+ checkSemanticModel(ident);
+ String semanticModelParent =
+ dot.join(ident.namespace().level(1), ident.namespace().level(2));
+ return MetadataObjects.of(
+ semanticModelParent, ident.name(),
MetadataObject.Type.SEMANTIC_MODEL);
+
case COLUMN:
checkColumn(ident);
Namespace columnNs = ident.namespace();
@@ -897,6 +929,7 @@ public class NameIdentifierUtil {
case TABLE:
case VIEW:
+ case SEMANTIC_MODEL:
case FILESET:
case MODEL:
case TOPIC:
diff --git a/core/src/main/java/org/apache/gravitino/utils/NamespaceUtil.java
b/core/src/main/java/org/apache/gravitino/utils/NamespaceUtil.java
index 13b380a81b..bd98e2bfcf 100644
--- a/core/src/main/java/org/apache/gravitino/utils/NamespaceUtil.java
+++ b/core/src/main/java/org/apache/gravitino/utils/NamespaceUtil.java
@@ -84,6 +84,18 @@ public class NamespaceUtil {
return Namespace.of(metalake, catalog, schema);
}
+ /**
+ * Create a namespace for a semantic model.
+ *
+ * @param metalake The metalake name
+ * @param catalog The catalog name
+ * @param schema The schema name
+ * @return A namespace for a semantic model
+ */
+ public static Namespace ofSemanticModel(String metalake, String catalog,
String schema) {
+ return Namespace.of(metalake, catalog, schema);
+ }
+
/**
* Create a namespace for tag.
*
@@ -309,6 +321,19 @@ public class NamespaceUtil {
namespace);
}
+ /**
+ * Check if the given semantic model namespace is legal, throw an {@link
+ * IllegalNamespaceException} if it's illegal.
+ *
+ * @param namespace The semantic model namespace
+ */
+ public static void checkSemanticModel(Namespace namespace) {
+ check(
+ namespace != null && namespace.length() == 3,
+ "Semantic model namespace must be non-null and have 3 levels, the
input namespace is %s",
+ namespace);
+ }
+
/**
* Check if the given column namespace is legal, throw an {@link
IllegalNamespaceException} if
* it's illegal.
diff --git
a/core/src/test/java/org/apache/gravitino/meta/TestSemanticModelEntity.java
b/core/src/test/java/org/apache/gravitino/meta/TestSemanticModelEntity.java
new file mode 100644
index 0000000000..a1f1c4420c
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/meta/TestSemanticModelEntity.java
@@ -0,0 +1,304 @@
+/*
+ * 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.meta;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.DialectExpression;
+import org.apache.gravitino.semantic.Dialects;
+import org.apache.gravitino.semantic.Expression;
+import org.apache.gravitino.semantic.Metric;
+import org.apache.gravitino.semantic.Relationship;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link SemanticModelEntity}. */
+public class TestSemanticModelEntity {
+
+ /** Tests the complete entity field contract and optional defaults. */
+ @Test
+ public void testSemanticModelEntityFields() {
+ SemanticModelDefinition definition = completeDefinition();
+ AuditInfo auditInfo = auditInfo();
+ Map<String, String> properties = Map.of("owner", "analytics");
+
+ SemanticModelEntity entity =
+ SemanticModelEntity.builder()
+ .withId(1L)
+ .withName("sales")
+ .withNamespace(Namespace.of("metalake", "catalog", "schema"))
+ .withComment("Sales model")
+ .withDefinition(definition)
+ .withProperties(properties)
+ .withAuditInfo(auditInfo)
+ .build();
+
+ assertEquals(1L, entity.id());
+ assertEquals("sales", entity.name());
+ assertEquals(Namespace.of("metalake", "catalog", "schema"),
entity.namespace());
+ assertEquals("Sales model", entity.comment());
+ assertSame(definition, entity.definition());
+ assertEquals(properties, entity.properties());
+ assertEquals(auditInfo, entity.auditInfo());
+ assertEquals(Entity.EntityType.SEMANTIC_MODEL, entity.type());
+ assertSame(definition,
entity.fields().get(SemanticModelEntity.DEFINITION));
+
+ SemanticModelDefinition minimalDefinition =
+ SemanticModelDefinition.builder().withDatasets(new Dataset[]
{dataset("orders")}).build();
+ SemanticModelEntity minimalEntity =
+ SemanticModelEntity.builder()
+ .withId(2L)
+ .withName("minimal")
+ .withNamespace(Namespace.of("metalake", "catalog", "schema"))
+ .withDefinition(minimalDefinition)
+ .withProperties(null)
+ .withAuditInfo(auditInfo)
+ .build();
+
+ assertNull(minimalEntity.comment());
+ assertNull(minimalEntity.definition().aiContext());
+ assertNull(minimalEntity.definition().relationships());
+ assertNull(minimalEntity.definition().metrics());
+ assertNull(minimalEntity.definition().customExtensions());
+ assertEquals(Map.of(), minimalEntity.properties());
+ }
+
+ /** Tests that absent and explicitly empty optional arrays remain
distinguishable. */
+ @Test
+ public void testPreservesNullAndEmptyOptionalArrays() {
+ Dataset dataset = dataset("orders");
+ SemanticModelDefinition absent =
+ SemanticModelDefinition.builder().withDatasets(new Dataset[]
{dataset}).build();
+ SemanticModelDefinition empty =
+ SemanticModelDefinition.builder()
+ .withDatasets(new Dataset[] {dataset})
+ .withRelationships(new Relationship[0])
+ .withMetrics(new Metric[0])
+ .withCustomExtensions(new CustomExtension[0])
+ .build();
+
+ SemanticModelEntity absentEntity = entity("absent", absent, Map.of());
+ SemanticModelEntity emptyEntity = entity("empty", empty, Map.of());
+
+ assertNull(absentEntity.definition().relationships());
+ assertNull(absentEntity.definition().metrics());
+ assertNull(absentEntity.definition().customExtensions());
+ assertArrayEquals(new Relationship[0],
emptyEntity.definition().relationships());
+ assertArrayEquals(new Metric[0], emptyEntity.definition().metrics());
+ assertArrayEquals(new CustomExtension[0],
emptyEntity.definition().customExtensions());
+ assertNotEquals(absentEntity.definition(), emptyEntity.definition());
+ }
+
+ /** Tests that definition arrays and properties cannot mutate the entity
snapshot. */
+ @Test
+ public void testDefensivelyCopiesStructuredCollections() {
+ Dataset[] datasets = {dataset("orders")};
+ Relationship[] relationships = {relationship("orders_to_customers")};
+ Metric[] metrics = {metric("order_count")};
+ CustomExtension[] extensions = {extension("example")};
+ Map<String, String> properties = new HashMap<>();
+ properties.put("owner", "analytics");
+
+ SemanticModelDefinition definition =
+ SemanticModelDefinition.builder()
+ .withAIContext(AIContext.of("Certified sales definitions"))
+ .withDatasets(datasets)
+ .withRelationships(relationships)
+ .withMetrics(metrics)
+ .withCustomExtensions(extensions)
+ .build();
+ SemanticModelEntity entity = entity("sales", definition, properties);
+
+ datasets[0] = dataset("changed");
+ relationships[0] = relationship("changed");
+ metrics[0] = metric("changed");
+ extensions[0] = extension("changed");
+ properties.put("owner", "changed");
+
+ assertEquals("orders", entity.definition().datasets()[0].name());
+ assertEquals("orders_to_customers",
entity.definition().relationships()[0].name());
+ assertEquals("order_count", entity.definition().metrics()[0].name());
+ assertEquals("example",
entity.definition().customExtensions()[0].vendorName());
+ assertEquals("analytics", entity.properties().get("owner"));
+
+ Dataset[] returnedDatasets = entity.definition().datasets();
+ Relationship[] returnedRelationships = entity.definition().relationships();
+ Metric[] returnedMetrics = entity.definition().metrics();
+ CustomExtension[] returnedExtensions =
entity.definition().customExtensions();
+ returnedDatasets[0] = dataset("returned");
+ returnedRelationships[0] = relationship("returned");
+ returnedMetrics[0] = metric("returned");
+ returnedExtensions[0] = extension("returned");
+
+ assertEquals("orders", entity.definition().datasets()[0].name());
+ assertEquals("orders_to_customers",
entity.definition().relationships()[0].name());
+ assertEquals("order_count", entity.definition().metrics()[0].name());
+ assertEquals("example",
entity.definition().customExtensions()[0].vendorName());
+ assertNotSame(entity.definition().datasets(),
entity.definition().datasets());
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> entity.properties().put("new-property", "value"));
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> entity.fields().put(SemanticModelEntity.COMMENT, "changed"));
+ }
+
+ /** Tests value-based equality and hash codes. */
+ @Test
+ public void testEqualsAndHashCode() {
+ SemanticModelDefinition definition = completeDefinition();
+ AuditInfo auditInfo = auditInfo();
+
+ SemanticModelEntity first =
+ SemanticModelEntity.builder()
+ .withId(1L)
+ .withName("sales")
+ .withNamespace(Namespace.of("metalake", "catalog", "schema"))
+ .withComment("Sales model")
+ .withDefinition(definition)
+ .withProperties(Map.of("owner", "analytics"))
+ .withAuditInfo(auditInfo)
+ .build();
+ SemanticModelEntity equal =
+ SemanticModelEntity.builder()
+ .withId(1L)
+ .withName("sales")
+ .withNamespace(Namespace.of("metalake", "catalog", "schema"))
+ .withComment("Sales model")
+ .withDefinition(definition)
+ .withProperties(Map.of("owner", "analytics"))
+ .withAuditInfo(auditInfo)
+ .build();
+ SemanticModelEntity different = entity("marketing", definition, Map.of());
+
+ assertEquals(first, equal);
+ assertEquals(first.hashCode(), equal.hashCode());
+ assertNotEquals(first, different);
+ assertNotEquals(first, new Object());
+ }
+
+ /** Tests validation of required entity fields. */
+ @Test
+ public void testRequiredFieldValidation() {
+ SemanticModelDefinition definition =
+ SemanticModelDefinition.builder().withDatasets(new Dataset[]
{dataset("orders")}).build();
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ SemanticModelEntity.builder()
+ .withId(1L)
+ .withName("sales")
+ .withDefinition(definition)
+ .withAuditInfo(auditInfo())
+ .build());
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ SemanticModelEntity.builder()
+ .withId(1L)
+ .withName("sales")
+ .withNamespace(Namespace.of("metalake", "catalog", "schema"))
+ .withAuditInfo(auditInfo())
+ .build());
+ }
+
+ private static SemanticModelEntity entity(
+ String name, SemanticModelDefinition definition, Map<String, String>
properties) {
+ return SemanticModelEntity.builder()
+ .withId(1L)
+ .withName(name)
+ .withNamespace(Namespace.of("metalake", "catalog", "schema"))
+ .withComment("Sales model")
+ .withDefinition(definition)
+ .withProperties(properties)
+ .withAuditInfo(auditInfo())
+ .build();
+ }
+
+ private static SemanticModelDefinition completeDefinition() {
+ return SemanticModelDefinition.builder()
+ .withAIContext(AIContext.of("Certified sales definitions"))
+ .withDatasets(new Dataset[] {dataset("orders")})
+ .withRelationships(new Relationship[]
{relationship("orders_to_customers")})
+ .withMetrics(new Metric[] {metric("order_count")})
+ .withCustomExtensions(new CustomExtension[] {extension("example")})
+ .build();
+ }
+
+ private static Dataset dataset(String name) {
+ return Dataset.builder()
+ .withName(name)
+ .withSource(NameIdentifier.of("sales", "mart", name))
+ .build();
+ }
+
+ private static Relationship relationship(String name) {
+ return Relationship.builder()
+ .withName(name)
+ .withFrom("orders")
+ .withTo("customers")
+ .withFromColumns(new String[] {"customer_id"})
+ .withToColumns(new String[] {"id"})
+ .build();
+ }
+
+ private static Metric metric(String name) {
+ return Metric.builder()
+ .withName(name)
+ .withExpression(
+ Expression.builder()
+ .withDialects(
+ new DialectExpression[] {
+ DialectExpression.builder()
+ .withDialect(Dialects.ANSI_SQL)
+ .withExpression("COUNT(*)")
+ .build()
+ })
+ .build())
+ .build();
+ }
+
+ private static CustomExtension extension(String vendorName) {
+ return
CustomExtension.builder().withVendorName(vendorName).withData("{}").build();
+ }
+
+ private static AuditInfo auditInfo() {
+ return AuditInfo.builder()
+ .withCreator("tester")
+ .withCreateTime(Instant.parse("2026-08-11T00:00:00Z"))
+ .build();
+ }
+}
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 97a7fef4a4..243d3d69a6 100644
--- a/core/src/test/java/org/apache/gravitino/utils/TestMetadataObjectUtil.java
+++ b/core/src/test/java/org/apache/gravitino/utils/TestMetadataObjectUtil.java
@@ -85,6 +85,12 @@ public class TestMetadataObjectUtil {
Entity.EntityType.FUNCTION,
MetadataObjectUtil.toEntityType(
MetadataObjects.of("catalog.schema", "function",
MetadataObject.Type.FUNCTION)));
+
+ Assertions.assertEquals(
+ Entity.EntityType.SEMANTIC_MODEL,
+ MetadataObjectUtil.toEntityType(
+ MetadataObjects.of(
+ "catalog.schema", "sales_model",
MetadataObject.Type.SEMANTIC_MODEL)));
}
@Test
@@ -158,6 +164,13 @@ public class TestMetadataObjectUtil {
MetadataObjectUtil.toEntityIdent(
"metalake",
MetadataObjects.of("catalog.schema", "function",
MetadataObject.Type.FUNCTION)));
+
+ Assertions.assertEquals(
+ NameIdentifier.of("metalake", "catalog", "schema", "sales_model"),
+ MetadataObjectUtil.toEntityIdent(
+ "metalake",
+ MetadataObjects.of(
+ "catalog.schema", "sales_model",
MetadataObject.Type.SEMANTIC_MODEL)));
}
@Test
diff --git
a/core/src/test/java/org/apache/gravitino/utils/TestNameIdentifierUtil.java
b/core/src/test/java/org/apache/gravitino/utils/TestNameIdentifierUtil.java
index 99967d2ad3..0567651247 100644
--- a/core/src/test/java/org/apache/gravitino/utils/TestNameIdentifierUtil.java
+++ b/core/src/test/java/org/apache/gravitino/utils/TestNameIdentifierUtil.java
@@ -142,6 +142,16 @@ public class TestNameIdentifierUtil {
MetadataObjects.parse("catalog1.schema1.view1",
MetadataObject.Type.VIEW);
assertEquals(viewObject, NameIdentifierUtil.toMetadataObject(view,
Entity.EntityType.VIEW));
+ // test semantic model
+ NameIdentifier semanticModel =
+ NameIdentifier.of("metalake1", "catalog1", "schema1",
"semantic_model1");
+ MetadataObject semanticModelObject =
+ MetadataObjects.parse(
+ "catalog1.schema1.semantic_model1",
MetadataObject.Type.SEMANTIC_MODEL);
+ assertEquals(
+ semanticModelObject,
+ NameIdentifierUtil.toMetadataObject(semanticModel,
Entity.EntityType.SEMANTIC_MODEL));
+
// test null
Throwable e1 =
assertThrows(
@@ -363,5 +373,18 @@ public class TestNameIdentifierUtil {
NameIdentifierUtil.buildNameIdentifier(Entity.EntityType.VIEW,
viewName, viewEntities);
assertEquals(NameIdentifier.of(metalake, catalog, schema, viewName),
viewIdent);
assertEquals(viewName, viewIdent.name());
+
+ // Test 14: Build a SEMANTIC_MODEL identifier
+ String semanticModelName = "my_semantic_model";
+ Map<Entity.EntityType, String> semanticModelEntities = Maps.newHashMap();
+ semanticModelEntities.put(Entity.EntityType.METALAKE, metalake);
+ semanticModelEntities.put(Entity.EntityType.CATALOG, catalog);
+ semanticModelEntities.put(Entity.EntityType.SCHEMA, schema);
+ NameIdentifier semanticModelIdent =
+ NameIdentifierUtil.buildNameIdentifier(
+ Entity.EntityType.SEMANTIC_MODEL, semanticModelName,
semanticModelEntities);
+ assertEquals(
+ NameIdentifier.of(metalake, catalog, schema, semanticModelName),
semanticModelIdent);
+ assertEquals(semanticModelName, semanticModelIdent.name());
}
}
diff --git
a/core/src/test/java/org/apache/gravitino/utils/TestNamespaceUtil.java
b/core/src/test/java/org/apache/gravitino/utils/TestNamespaceUtil.java
index 5255d661b8..b9083582cc 100644
--- a/core/src/test/java/org/apache/gravitino/utils/TestNamespaceUtil.java
+++ b/core/src/test/java/org/apache/gravitino/utils/TestNamespaceUtil.java
@@ -91,6 +91,17 @@ public class TestNamespaceUtil {
IllegalNamespaceException.class, () ->
NamespaceUtil.checkView(abcd));
Assertions.assertTrue(
excep6.getMessage().contains("View namespace must be non-null and have
3 levels"));
+
+ // Test semantic model
+ Assertions.assertThrows(
+ IllegalNamespaceException.class, () ->
NamespaceUtil.checkSemanticModel(null));
+ Throwable excep7 =
+ Assertions.assertThrows(
+ IllegalNamespaceException.class, () ->
NamespaceUtil.checkSemanticModel(abcd));
+ Assertions.assertTrue(
+ excep7
+ .getMessage()
+ .contains("Semantic model namespace must be non-null and have 3
levels"));
}
@Test