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

Reply via email to