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 e8ff800bbd [#12606] feat(common): Add Semantic Model definition DTOs 
(#12624)
e8ff800bbd is described below

commit e8ff800bbd470ff658652368251a449cfd89f4b7
Author: mchades <[email protected]>
AuthorDate: Thu Aug 27 13:56:06 2026 +0800

    [#12606] feat(common): Add Semantic Model definition DTOs (#12624)
    
    ### What changes were proposed in this pull request?
    
    This PR adds reusable DTOs for Semantic Model definitions, including AI
    context, datasets, fields, expressions, relationships, metrics,
    dimensions, and custom extensions.
    
    The DTOs support JSON serialization and API conversion while preserving
    null-versus-empty arrays and AI context additional properties.
    
    ### Why are the changes needed?
    
    These DTOs provide the reusable definition serialization layer required
    by Semantic Model management capabilities.
    
    Fix: #12606
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. It adds Java DTOs and JSON conversion support for Semantic Model
    definitions. This PR does not add REST APIs, server operations, OpenAPI
    definitions, or client changes.
    
    ### How was this patch tested?
    
    All local checks passed.
---
 .../gravitino/dto/semantic/AIContextDTO.java       | 122 +++++
 .../gravitino/dto/semantic/AIContextObjectDTO.java | 233 +++++++++
 .../gravitino/dto/semantic/CustomExtensionDTO.java |  64 +++
 .../apache/gravitino/dto/semantic/DatasetDTO.java  | 203 ++++++++
 .../dto/semantic/DialectExpressionDTO.java         |  67 +++
 .../gravitino/dto/semantic/DimensionDTO.java       |  63 +++
 .../gravitino/dto/semantic/ExpressionDTO.java      |  83 ++++
 .../apache/gravitino/dto/semantic/FieldDTO.java    | 167 +++++++
 .../apache/gravitino/dto/semantic/MetricDTO.java   | 149 ++++++
 .../gravitino/dto/semantic/RelationshipDTO.java    | 169 +++++++
 .../gravitino/dto/semantic/SemanticDTOUtils.java   | 209 ++++++++
 .../dto/semantic/SemanticModelDefinitionDTO.java   | 189 +++++++
 .../dto/semantic/TestSemanticDTOUtils.java         | 171 +++++++
 .../semantic/TestSemanticModelDefinitionDTO.java   | 545 +++++++++++++++++++++
 14 files changed, 2434 insertions(+)

diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/AIContextDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/AIContextDTO.java
new file mode 100644
index 0000000000..4c3b1ae011
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/semantic/AIContextDTO.java
@@ -0,0 +1,122 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import javax.annotation.Nullable;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import org.apache.gravitino.semantic.AIContext;
+
+/** DTO for the string-or-object Semantic Model AI context union. */
+@Getter
+@EqualsAndHashCode
+@JsonSerialize(using = AIContextDTO.Serializer.class)
+@JsonDeserialize(using = AIContextDTO.Deserializer.class)
+public class AIContextDTO {
+
+  @Nullable private final String text;
+  @Nullable private final AIContextObjectDTO object;
+
+  @Builder(setterPrefix = "with")
+  private AIContextDTO(@Nullable String text, @Nullable AIContextObjectDTO 
object) {
+    Preconditions.checkArgument(
+        (text == null) != (object == null),
+        "AI context must contain exactly one of text or object");
+    this.text = text;
+    this.object = object;
+  }
+
+  /**
+   * Creates an AI context DTO from an API model.
+   *
+   * @param aiContext The API AI context.
+   * @return The AI context DTO.
+   */
+  public static AIContextDTO fromAIContext(AIContext aiContext) {
+    if (aiContext.isText()) {
+      return builder().withText(aiContext.text()).build();
+    }
+    return 
builder().withObject(AIContextObjectDTO.fromAIContextObject(aiContext.object())).build();
+  }
+
+  /**
+   * Converts this DTO to an API AI context.
+   *
+   * @return The API AI context.
+   */
+  public AIContext toAIContext() {
+    if (text != null) {
+      return AIContext.of(text);
+    }
+    Preconditions.checkArgument(object != null, "AI context object must not be 
null");
+    return AIContext.of(object.toAIContextObject());
+  }
+
+  /** Serializes the AI context union as its contained string or object. */
+  public static final class Serializer extends JsonSerializer<AIContextDTO> {
+
+    @Override
+    public void serialize(
+        AIContextDTO value, JsonGenerator generator, SerializerProvider 
serializers)
+        throws IOException {
+      if (value.text != null && value.object == null) {
+        generator.writeString(value.text);
+      } else if (value.text == null && value.object != null) {
+        generator.writeObject(value.object);
+      } else {
+        throw JsonMappingException.from(
+            generator, "AI context must contain exactly one of text or 
object");
+      }
+    }
+  }
+
+  /** Deserializes an AI context union from a string or object. */
+  public static final class Deserializer extends 
JsonDeserializer<AIContextDTO> {
+
+    @Override
+    public AIContextDTO deserialize(JsonParser parser, DeserializationContext 
context)
+        throws IOException {
+      JsonToken token = parser.currentToken();
+      if (token == null) {
+        token = parser.nextToken();
+      }
+      if (token == JsonToken.VALUE_STRING) {
+        return builder().withText(parser.getText()).build();
+      }
+      if (token == JsonToken.START_OBJECT) {
+        AIContextObjectDTO object = parser.getCodec().readValue(parser, 
AIContextObjectDTO.class);
+        return builder().withObject(object).build();
+      }
+      throw JsonMappingException.from(parser, "AI context must be a string or 
object");
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/AIContextObjectDTO.java
 
b/common/src/main/java/org/apache/gravitino/dto/semantic/AIContextObjectDTO.java
new file mode 100644
index 0000000000..5f62c4f1e6
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/semantic/AIContextObjectDTO.java
@@ -0,0 +1,233 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.AIContextObject;
+
+/** DTO for structured Semantic Model AI context. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"instructions", "synonyms", "examples"})
+@JsonDeserialize(using = AIContextObjectDTO.Deserializer.class)
+public class AIContextObjectDTO {
+
+  @Nullable
+  @JsonProperty("instructions")
+  private String instructions;
+
+  @Nullable
+  @JsonProperty("synonyms")
+  @Getter(AccessLevel.NONE)
+  private String[] synonyms;
+
+  @Nullable
+  @JsonProperty("examples")
+  @Getter(AccessLevel.NONE)
+  private String[] examples;
+
+  @JsonIgnore
+  @Getter(AccessLevel.NONE)
+  private Map<String, Object> additionalProperties = new LinkedHashMap<>();
+
+  @Builder(setterPrefix = "with")
+  private AIContextObjectDTO(
+      @Nullable String instructions,
+      @Nullable String[] synonyms,
+      @Nullable String[] examples,
+      @Nullable Map<String, Object> additionalProperties) {
+    AIContextObject normalized =
+        AIContextObject.builder()
+            .withInstructions(instructions)
+            .withSynonyms(synonyms)
+            .withExamples(examples)
+            .withAdditionalProperties(
+                additionalProperties == null ? Collections.emptyMap() : 
additionalProperties)
+            .build();
+    this.instructions = normalized.instructions();
+    this.synonyms = normalized.synonyms();
+    this.examples = normalized.examples();
+    this.additionalProperties = normalized.additionalProperties();
+  }
+
+  /**
+   * Returns alternative names and terms.
+   *
+   * @return A defensive copy of the synonyms, or {@code null} when not 
provided.
+   */
+  @Nullable
+  public String[] getSynonyms() {
+    return SemanticDTOUtils.copyArray(synonyms);
+  }
+
+  /**
+   * Returns sample questions or use cases.
+   *
+   * @return A defensive copy of the examples, or {@code null} when not 
provided.
+   */
+  @Nullable
+  public String[] getExamples() {
+    return SemanticDTOUtils.copyArray(examples);
+  }
+
+  /**
+   * Creates a structured AI context DTO from an API model.
+   *
+   * @param aiContextObject The API AI context object.
+   * @return The structured AI context DTO.
+   */
+  public static AIContextObjectDTO fromAIContextObject(AIContextObject 
aiContextObject) {
+    return builder()
+        .withInstructions(aiContextObject.instructions())
+        .withSynonyms(aiContextObject.synonyms())
+        .withExamples(aiContextObject.examples())
+        .withAdditionalProperties(new 
LinkedHashMap<>(aiContextObject.additionalProperties()))
+        .build();
+  }
+
+  /**
+   * Converts this DTO to an API AI context object.
+   *
+   * @return The API AI context object.
+   */
+  public AIContextObject toAIContextObject() {
+    return AIContextObject.builder()
+        .withInstructions(instructions)
+        .withSynonyms(synonyms)
+        .withExamples(examples)
+        .withAdditionalProperties(
+            additionalProperties == null ? Collections.emptyMap() : 
additionalProperties)
+        .build();
+  }
+
+  /**
+   * Returns unknown AI-context properties in their input order.
+   *
+   * <p>The returned map and all nested maps and lists are unmodifiable.
+   *
+   * @return The deeply immutable additional properties.
+   */
+  @JsonAnyGetter
+  public Map<String, Object> getAdditionalProperties() {
+    return additionalProperties == null ? Collections.emptyMap() : 
additionalProperties;
+  }
+
+  /** Deserializes structured AI context while retaining unknown JSON values 
and their order. */
+  public static final class Deserializer extends 
JsonDeserializer<AIContextObjectDTO> {
+
+    @Override
+    public AIContextObjectDTO deserialize(JsonParser parser, 
DeserializationContext context)
+        throws IOException {
+      if (!parser.hasToken(JsonToken.START_OBJECT)) {
+        throw JsonMappingException.from(
+            parser, "Structured AI context must be an object, but found " + 
parser.currentToken());
+      }
+
+      String instructions = null;
+      String[] synonyms = null;
+      String[] examples = null;
+      Map<String, Object> additionalProperties = new LinkedHashMap<>();
+      while (parser.nextToken() != JsonToken.END_OBJECT) {
+        if (!parser.hasToken(JsonToken.FIELD_NAME)) {
+          throw JsonMappingException.from(
+              parser, "Expected an AI context property name, but found " + 
parser.currentToken());
+        }
+        String name = parser.currentName();
+        JsonToken valueToken = parser.nextToken();
+        switch (name) {
+          case "instructions":
+            instructions = readNullableString(parser, valueToken, name);
+            break;
+          case "synonyms":
+            synonyms = readNullableStringArray(parser, valueToken, name);
+            break;
+          case "examples":
+            examples = readNullableStringArray(parser, valueToken, name);
+            break;
+          default:
+            additionalProperties.put(name, 
SemanticDTOUtils.readJsonValue(parser, valueToken));
+        }
+      }
+
+      return AIContextObjectDTO.builder()
+          .withInstructions(instructions)
+          .withSynonyms(synonyms)
+          .withExamples(examples)
+          .withAdditionalProperties(additionalProperties)
+          .build();
+    }
+
+    @Nullable
+    private static String readNullableString(JsonParser parser, JsonToken 
token, String name)
+        throws IOException {
+      if (token == JsonToken.VALUE_NULL) {
+        return null;
+      }
+      return readRequiredString(parser, token, name);
+    }
+
+    @Nullable
+    private static String[] readNullableStringArray(JsonParser parser, 
JsonToken token, String name)
+        throws IOException {
+      if (token == JsonToken.VALUE_NULL) {
+        return null;
+      }
+      if (token != JsonToken.START_ARRAY) {
+        throw JsonMappingException.from(parser, name + " must be an array of 
strings");
+      }
+      List<String> values = new ArrayList<>();
+      while (parser.nextToken() != JsonToken.END_ARRAY) {
+        values.add(readRequiredString(parser, parser.currentToken(), name));
+      }
+      return values.toArray(new String[0]);
+    }
+
+    private static String readRequiredString(JsonParser parser, JsonToken 
token, String name)
+        throws IOException {
+      if (token != JsonToken.VALUE_STRING) {
+        throw JsonMappingException.from(parser, name + " must be a string");
+      }
+      return parser.getText();
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/CustomExtensionDTO.java
 
b/common/src/main/java/org/apache/gravitino/dto/semantic/CustomExtensionDTO.java
new file mode 100644
index 0000000000..fa4043aca1
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/semantic/CustomExtensionDTO.java
@@ -0,0 +1,64 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.CustomExtension;
+
+/** DTO for a vendor-specific Semantic Model extension. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder(setterPrefix = "with")
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CustomExtensionDTO {
+
+  @JsonProperty("vendorName")
+  private String vendorName;
+
+  @JsonProperty("data")
+  private String data;
+
+  /**
+   * Creates a custom extension DTO from an API model.
+   *
+   * @param extension The API custom extension.
+   * @return The custom extension DTO.
+   */
+  public static CustomExtensionDTO fromCustomExtension(CustomExtension 
extension) {
+    return 
builder().withVendorName(extension.vendorName()).withData(extension.data()).build();
+  }
+
+  /**
+   * Converts this DTO to an API custom extension.
+   *
+   * @return The API custom extension.
+   */
+  public CustomExtension toCustomExtension() {
+    return 
CustomExtension.builder().withVendorName(vendorName).withData(data).build();
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/DatasetDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/DatasetDTO.java
new file mode 100644
index 0000000000..a2708ba8e4
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/semantic/DatasetDTO.java
@@ -0,0 +1,203 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.Field;
+
+/** DTO for a dataset in a Semantic Model definition. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class DatasetDTO {
+
+  @JsonProperty("name")
+  private String name;
+
+  @JsonProperty("source")
+  @JsonSerialize(using = JsonUtils.NameIdentifierSerializer.class)
+  @JsonDeserialize(using = JsonUtils.NameIdentifierDeserializer.class)
+  private NameIdentifier source;
+
+  @Nullable
+  @JsonProperty("primaryKey")
+  @Getter(AccessLevel.NONE)
+  private String[] primaryKey;
+
+  @Nullable
+  @JsonProperty("uniqueKeys")
+  @Getter(AccessLevel.NONE)
+  private String[][] uniqueKeys;
+
+  @Nullable
+  @JsonProperty("description")
+  private String description;
+
+  @Nullable
+  @JsonProperty("aiContext")
+  private AIContextDTO aiContext;
+
+  @Nullable
+  @JsonProperty("fields")
+  @Getter(AccessLevel.NONE)
+  private FieldDTO[] fields;
+
+  @Nullable
+  @JsonProperty("customExtensions")
+  @Getter(AccessLevel.NONE)
+  private CustomExtensionDTO[] customExtensions;
+
+  @Builder(setterPrefix = "with")
+  private DatasetDTO(
+      String name,
+      NameIdentifier source,
+      @Nullable String[] primaryKey,
+      @Nullable String[][] uniqueKeys,
+      @Nullable String description,
+      @Nullable AIContextDTO aiContext,
+      @Nullable FieldDTO[] fields,
+      @Nullable CustomExtensionDTO[] customExtensions) {
+    this.name = name;
+    this.source = source;
+    this.primaryKey = SemanticDTOUtils.copyArray(primaryKey);
+    this.uniqueKeys = SemanticDTOUtils.copy2DArray(uniqueKeys);
+    this.description = description;
+    this.aiContext = aiContext;
+    this.fields = SemanticDTOUtils.copyArray(fields);
+    this.customExtensions = SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Returns the primary key columns.
+   *
+   * @return A defensive copy of the primary key columns, or {@code null} when 
not provided.
+   */
+  @Nullable
+  public String[] getPrimaryKey() {
+    return SemanticDTOUtils.copyArray(primaryKey);
+  }
+
+  /**
+   * Returns the unique key definitions.
+   *
+   * @return A deep defensive copy of the unique keys, or {@code null} when 
not provided.
+   */
+  @Nullable
+  public String[][] getUniqueKeys() {
+    return SemanticDTOUtils.copy2DArray(uniqueKeys);
+  }
+
+  /**
+   * Returns the fields defined by the dataset.
+   *
+   * @return A defensive copy of the fields, or {@code null} when not provided.
+   */
+  @Nullable
+  public FieldDTO[] getFields() {
+    return SemanticDTOUtils.copyArray(fields);
+  }
+
+  /**
+   * Returns the custom extensions associated with the dataset.
+   *
+   * @return A defensive copy of the custom extensions, or {@code null} when 
not provided.
+   */
+  @Nullable
+  public CustomExtensionDTO[] getCustomExtensions() {
+    return SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Creates a dataset DTO from an API model.
+   *
+   * @param dataset The API dataset.
+   * @return The dataset DTO.
+   */
+  public static DatasetDTO fromDataset(Dataset dataset) {
+    AIContext sourceAIContext = dataset.aiContext();
+    return builder()
+        .withName(dataset.name())
+        .withSource(dataset.source())
+        .withPrimaryKey(dataset.primaryKey())
+        .withUniqueKeys(dataset.uniqueKeys())
+        .withDescription(dataset.description())
+        .withAiContext(sourceAIContext == null ? null : 
AIContextDTO.fromAIContext(sourceAIContext))
+        .withFields(
+            SemanticDTOUtils.convertArray(dataset.fields(), 
FieldDTO::fromField, FieldDTO[]::new))
+        .withCustomExtensions(
+            SemanticDTOUtils.convertArray(
+                dataset.customExtensions(),
+                CustomExtensionDTO::fromCustomExtension,
+                CustomExtensionDTO[]::new))
+        .build();
+  }
+
+  /**
+   * Converts this DTO to an API dataset.
+   *
+   * @return The API dataset.
+   */
+  public Dataset toDataset() {
+    Field[] convertedFields =
+        SemanticDTOUtils.convertArray(fields, FieldDTO::toField, Field[]::new);
+    CustomExtension[] convertedExtensions =
+        SemanticDTOUtils.convertArray(
+            customExtensions, CustomExtensionDTO::toCustomExtension, 
CustomExtension[]::new);
+    return Dataset.builder()
+        .withName(name)
+        .withSource(source)
+        .withPrimaryKey(primaryKey)
+        .withUniqueKeys(uniqueKeys)
+        .withDescription(description)
+        .withAIContext(aiContext == null ? null : aiContext.toAIContext())
+        .withFields(convertedFields)
+        .withCustomExtensions(convertedExtensions)
+        .build();
+  }
+
+  /** Builder for {@link DatasetDTO}. */
+  public static class DatasetDTOBuilder {
+
+    /**
+     * Sets the optional AI context.
+     *
+     * @param aiContext The AI context DTO.
+     * @return This builder.
+     */
+    public DatasetDTOBuilder withAiContext(@Nullable AIContextDTO aiContext) {
+      this.aiContext = aiContext;
+      return this;
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/DialectExpressionDTO.java
 
b/common/src/main/java/org/apache/gravitino/dto/semantic/DialectExpressionDTO.java
new file mode 100644
index 0000000000..7235231719
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/semantic/DialectExpressionDTO.java
@@ -0,0 +1,67 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.DialectExpression;
+
+/** DTO for an expression written in a specific Semantic Model dialect. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder(setterPrefix = "with")
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class DialectExpressionDTO {
+
+  @JsonProperty("dialect")
+  private String dialect;
+
+  @JsonProperty("expression")
+  private String expression;
+
+  /**
+   * Creates a dialect expression DTO from an API model.
+   *
+   * @param dialectExpression The API dialect expression.
+   * @return The dialect expression DTO.
+   */
+  public static DialectExpressionDTO fromDialectExpression(DialectExpression 
dialectExpression) {
+    return builder()
+        .withDialect(dialectExpression.dialect())
+        .withExpression(dialectExpression.expression())
+        .build();
+  }
+
+  /**
+   * Converts this DTO to an API dialect expression.
+   *
+   * @return The API dialect expression.
+   */
+  public DialectExpression toDialectExpression() {
+    return 
DialectExpression.builder().withDialect(dialect).withExpression(expression).build();
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/DimensionDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/DimensionDTO.java
new file mode 100644
index 0000000000..c51f59153f
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/semantic/DimensionDTO.java
@@ -0,0 +1,63 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.Dimension;
+
+/** DTO for Semantic Model dimension metadata. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@Builder(setterPrefix = "with")
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class DimensionDTO {
+
+  @Nullable
+  @JsonProperty("isTime")
+  private Boolean isTime;
+
+  /**
+   * Creates a dimension DTO from an API model.
+   *
+   * @param dimension The API dimension.
+   * @return The dimension DTO.
+   */
+  public static DimensionDTO fromDimension(Dimension dimension) {
+    return builder().withIsTime(dimension.isTime()).build();
+  }
+
+  /**
+   * Converts this DTO to API dimension metadata.
+   *
+   * @return The API dimension metadata.
+   */
+  public Dimension toDimension() {
+    return Dimension.builder().withIsTime(isTime).build();
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/ExpressionDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/ExpressionDTO.java
new file mode 100644
index 0000000000..27cf1e791e
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/semantic/ExpressionDTO.java
@@ -0,0 +1,83 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.DialectExpression;
+import org.apache.gravitino.semantic.Expression;
+
+/** DTO for a multi-dialect Semantic Model expression. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ExpressionDTO {
+
+  @JsonProperty("dialects")
+  @Getter(AccessLevel.NONE)
+  private DialectExpressionDTO[] dialects;
+
+  @Builder(setterPrefix = "with")
+  private ExpressionDTO(DialectExpressionDTO[] dialects) {
+    this.dialects = SemanticDTOUtils.copyArray(dialects);
+  }
+
+  /**
+   * Returns the ordered dialect-specific expressions.
+   *
+   * @return A defensive copy of the dialect expressions.
+   */
+  public DialectExpressionDTO[] getDialects() {
+    return SemanticDTOUtils.copyArray(dialects);
+  }
+
+  /**
+   * Creates an expression DTO from an API model.
+   *
+   * @param expression The API expression.
+   * @return The expression DTO.
+   */
+  public static ExpressionDTO fromExpression(Expression expression) {
+    return builder()
+        .withDialects(
+            SemanticDTOUtils.convertArray(
+                expression.dialects(),
+                DialectExpressionDTO::fromDialectExpression,
+                DialectExpressionDTO[]::new))
+        .build();
+  }
+
+  /**
+   * Converts this DTO to an API expression.
+   *
+   * @return The API expression.
+   */
+  public Expression toExpression() {
+    DialectExpression[] convertedDialects =
+        SemanticDTOUtils.convertArray(
+            dialects, DialectExpressionDTO::toDialectExpression, 
DialectExpression[]::new);
+    return Expression.builder().withDialects(convertedDialects).build();
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/FieldDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/FieldDTO.java
new file mode 100644
index 0000000000..6f06d0dc5c
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/semantic/FieldDTO.java
@@ -0,0 +1,167 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.DataType;
+import org.apache.gravitino.semantic.Dimension;
+import org.apache.gravitino.semantic.Field;
+
+/** DTO for a field in a Semantic Model dataset. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class FieldDTO {
+
+  @JsonProperty("name")
+  private String name;
+
+  @JsonProperty("expression")
+  private ExpressionDTO expression;
+
+  @Nullable
+  @JsonProperty("dimension")
+  private DimensionDTO dimension;
+
+  @Nullable
+  @JsonProperty("label")
+  private String label;
+
+  @Nullable
+  @JsonProperty("description")
+  private String description;
+
+  @Nullable
+  @JsonProperty("datatype")
+  @JsonSerialize(using = SemanticDTOUtils.DataTypeSerializer.class)
+  @JsonDeserialize(using = SemanticDTOUtils.DataTypeDeserializer.class)
+  private DataType datatype;
+
+  @Nullable
+  @JsonProperty("aiContext")
+  private AIContextDTO aiContext;
+
+  @Nullable
+  @JsonProperty("customExtensions")
+  @Getter(AccessLevel.NONE)
+  private CustomExtensionDTO[] customExtensions;
+
+  @Builder(setterPrefix = "with")
+  private FieldDTO(
+      String name,
+      ExpressionDTO expression,
+      @Nullable DimensionDTO dimension,
+      @Nullable String label,
+      @Nullable String description,
+      @Nullable DataType datatype,
+      @Nullable AIContextDTO aiContext,
+      @Nullable CustomExtensionDTO[] customExtensions) {
+    this.name = name;
+    this.expression = expression;
+    this.dimension = dimension;
+    this.label = label;
+    this.description = description;
+    this.datatype = datatype;
+    this.aiContext = aiContext;
+    this.customExtensions = SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Returns the custom extensions associated with the field.
+   *
+   * @return A defensive copy of the custom extensions, or {@code null} when 
not provided.
+   */
+  @Nullable
+  public CustomExtensionDTO[] getCustomExtensions() {
+    return SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Creates a field DTO from an API model.
+   *
+   * @param field The API field.
+   * @return The field DTO.
+   */
+  public static FieldDTO fromField(Field field) {
+    Dimension sourceDimension = field.dimension();
+    AIContext sourceAIContext = field.aiContext();
+    return builder()
+        .withName(field.name())
+        .withExpression(ExpressionDTO.fromExpression(field.expression()))
+        .withDimension(sourceDimension == null ? null : 
DimensionDTO.fromDimension(sourceDimension))
+        .withLabel(field.label())
+        .withDescription(field.description())
+        .withDatatype(field.datatype())
+        .withAiContext(sourceAIContext == null ? null : 
AIContextDTO.fromAIContext(sourceAIContext))
+        .withCustomExtensions(
+            SemanticDTOUtils.convertArray(
+                field.customExtensions(),
+                CustomExtensionDTO::fromCustomExtension,
+                CustomExtensionDTO[]::new))
+        .build();
+  }
+
+  /**
+   * Converts this DTO to an API field.
+   *
+   * @return The API field.
+   */
+  public Field toField() {
+    CustomExtension[] convertedExtensions =
+        SemanticDTOUtils.convertArray(
+            customExtensions, CustomExtensionDTO::toCustomExtension, 
CustomExtension[]::new);
+    return Field.builder()
+        .withName(name)
+        .withExpression(expression == null ? null : expression.toExpression())
+        .withDimension(dimension == null ? null : dimension.toDimension())
+        .withLabel(label)
+        .withDescription(description)
+        .withDatatype(datatype)
+        .withAIContext(aiContext == null ? null : aiContext.toAIContext())
+        .withCustomExtensions(convertedExtensions)
+        .build();
+  }
+
+  /** Builder for {@link FieldDTO}. */
+  public static class FieldDTOBuilder {
+
+    /**
+     * Sets the optional AI context.
+     *
+     * @param aiContext The AI context DTO.
+     * @return This builder.
+     */
+    public FieldDTOBuilder withAiContext(@Nullable AIContextDTO aiContext) {
+      this.aiContext = aiContext;
+      return this;
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/MetricDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/MetricDTO.java
new file mode 100644
index 0000000000..98d8f8f967
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/dto/semantic/MetricDTO.java
@@ -0,0 +1,149 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.DataType;
+import org.apache.gravitino.semantic.Metric;
+
+/** DTO for a model-scoped Semantic Model metric. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class MetricDTO {
+
+  @JsonProperty("name")
+  private String name;
+
+  @JsonProperty("expression")
+  private ExpressionDTO expression;
+
+  @Nullable
+  @JsonProperty("description")
+  private String description;
+
+  @Nullable
+  @JsonProperty("datatype")
+  @JsonSerialize(using = SemanticDTOUtils.DataTypeSerializer.class)
+  @JsonDeserialize(using = SemanticDTOUtils.DataTypeDeserializer.class)
+  private DataType datatype;
+
+  @Nullable
+  @JsonProperty("aiContext")
+  private AIContextDTO aiContext;
+
+  @Nullable
+  @JsonProperty("customExtensions")
+  @Getter(AccessLevel.NONE)
+  private CustomExtensionDTO[] customExtensions;
+
+  @Builder(setterPrefix = "with")
+  private MetricDTO(
+      String name,
+      ExpressionDTO expression,
+      @Nullable String description,
+      @Nullable DataType datatype,
+      @Nullable AIContextDTO aiContext,
+      @Nullable CustomExtensionDTO[] customExtensions) {
+    this.name = name;
+    this.expression = expression;
+    this.description = description;
+    this.datatype = datatype;
+    this.aiContext = aiContext;
+    this.customExtensions = SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Returns the custom extensions associated with the metric.
+   *
+   * @return A defensive copy of the custom extensions, or {@code null} when 
not provided.
+   */
+  @Nullable
+  public CustomExtensionDTO[] getCustomExtensions() {
+    return SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Creates a metric DTO from an API model.
+   *
+   * @param metric The API metric.
+   * @return The metric DTO.
+   */
+  public static MetricDTO fromMetric(Metric metric) {
+    AIContext sourceAIContext = metric.aiContext();
+    return builder()
+        .withName(metric.name())
+        .withExpression(ExpressionDTO.fromExpression(metric.expression()))
+        .withDescription(metric.description())
+        .withDatatype(metric.datatype())
+        .withAiContext(sourceAIContext == null ? null : 
AIContextDTO.fromAIContext(sourceAIContext))
+        .withCustomExtensions(
+            SemanticDTOUtils.convertArray(
+                metric.customExtensions(),
+                CustomExtensionDTO::fromCustomExtension,
+                CustomExtensionDTO[]::new))
+        .build();
+  }
+
+  /**
+   * Converts this DTO to an API metric.
+   *
+   * @return The API metric.
+   */
+  public Metric toMetric() {
+    CustomExtension[] convertedExtensions =
+        SemanticDTOUtils.convertArray(
+            customExtensions, CustomExtensionDTO::toCustomExtension, 
CustomExtension[]::new);
+    return Metric.builder()
+        .withName(name)
+        .withExpression(expression == null ? null : expression.toExpression())
+        .withDescription(description)
+        .withDatatype(datatype)
+        .withAIContext(aiContext == null ? null : aiContext.toAIContext())
+        .withCustomExtensions(convertedExtensions)
+        .build();
+  }
+
+  /** Builder for {@link MetricDTO}. */
+  public static class MetricDTOBuilder {
+
+    /**
+     * Sets the optional AI context.
+     *
+     * @param aiContext The AI context DTO.
+     * @return This builder.
+     */
+    public MetricDTOBuilder withAiContext(@Nullable AIContextDTO aiContext) {
+      this.aiContext = aiContext;
+      return this;
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/RelationshipDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/RelationshipDTO.java
new file mode 100644
index 0000000000..8aa22e8e1a
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/semantic/RelationshipDTO.java
@@ -0,0 +1,169 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.Relationship;
+
+/** DTO for a relationship between Semantic Model datasets. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class RelationshipDTO {
+
+  @JsonProperty("name")
+  private String name;
+
+  @JsonProperty("from")
+  private String from;
+
+  @JsonProperty("to")
+  private String to;
+
+  @JsonProperty("fromColumns")
+  @Getter(AccessLevel.NONE)
+  private String[] fromColumns;
+
+  @JsonProperty("toColumns")
+  @Getter(AccessLevel.NONE)
+  private String[] toColumns;
+
+  @Nullable
+  @JsonProperty("aiContext")
+  private AIContextDTO aiContext;
+
+  @Nullable
+  @JsonProperty("customExtensions")
+  @Getter(AccessLevel.NONE)
+  private CustomExtensionDTO[] customExtensions;
+
+  @Builder(setterPrefix = "with")
+  private RelationshipDTO(
+      String name,
+      String from,
+      String to,
+      String[] fromColumns,
+      String[] toColumns,
+      @Nullable AIContextDTO aiContext,
+      @Nullable CustomExtensionDTO[] customExtensions) {
+    this.name = name;
+    this.from = from;
+    this.to = to;
+    this.fromColumns = SemanticDTOUtils.copyArray(fromColumns);
+    this.toColumns = SemanticDTOUtils.copyArray(toColumns);
+    this.aiContext = aiContext;
+    this.customExtensions = SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Returns the source columns.
+   *
+   * @return A defensive copy of the source columns.
+   */
+  public String[] getFromColumns() {
+    return SemanticDTOUtils.copyArray(fromColumns);
+  }
+
+  /**
+   * Returns the target columns.
+   *
+   * @return A defensive copy of the target columns.
+   */
+  public String[] getToColumns() {
+    return SemanticDTOUtils.copyArray(toColumns);
+  }
+
+  /**
+   * Returns the custom extensions associated with the relationship.
+   *
+   * @return A defensive copy of the custom extensions, or {@code null} when 
not provided.
+   */
+  @Nullable
+  public CustomExtensionDTO[] getCustomExtensions() {
+    return SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Creates a relationship DTO from an API model.
+   *
+   * @param relationship The API relationship.
+   * @return The relationship DTO.
+   */
+  public static RelationshipDTO fromRelationship(Relationship relationship) {
+    AIContext sourceAIContext = relationship.aiContext();
+    return builder()
+        .withName(relationship.name())
+        .withFrom(relationship.from())
+        .withTo(relationship.to())
+        .withFromColumns(relationship.fromColumns())
+        .withToColumns(relationship.toColumns())
+        .withAiContext(sourceAIContext == null ? null : 
AIContextDTO.fromAIContext(sourceAIContext))
+        .withCustomExtensions(
+            SemanticDTOUtils.convertArray(
+                relationship.customExtensions(),
+                CustomExtensionDTO::fromCustomExtension,
+                CustomExtensionDTO[]::new))
+        .build();
+  }
+
+  /**
+   * Converts this DTO to an API relationship.
+   *
+   * @return The API relationship.
+   */
+  public Relationship toRelationship() {
+    CustomExtension[] convertedExtensions =
+        SemanticDTOUtils.convertArray(
+            customExtensions, CustomExtensionDTO::toCustomExtension, 
CustomExtension[]::new);
+    return Relationship.builder()
+        .withName(name)
+        .withFrom(from)
+        .withTo(to)
+        .withFromColumns(fromColumns)
+        .withToColumns(toColumns)
+        .withAIContext(aiContext == null ? null : aiContext.toAIContext())
+        .withCustomExtensions(convertedExtensions)
+        .build();
+  }
+
+  /** Builder for {@link RelationshipDTO}. */
+  public static class RelationshipDTOBuilder {
+
+    /**
+     * Sets the optional AI context.
+     *
+     * @param aiContext The AI context DTO.
+     * @return This builder.
+     */
+    public RelationshipDTOBuilder withAiContext(@Nullable AIContextDTO 
aiContext) {
+      this.aiContext = aiContext;
+      return this;
+    }
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticDTOUtils.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticDTOUtils.java
new file mode 100644
index 0000000000..e2860ee92f
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticDTOUtils.java
@@ -0,0 +1,209 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.IntFunction;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.apache.gravitino.semantic.DataType;
+
+final class SemanticDTOUtils {
+
+  private SemanticDTOUtils() {}
+
+  @Nullable
+  static <T> T[] copyArray(@Nullable T[] values) {
+    return values == null ? null : Arrays.copyOf(values, values.length);
+  }
+
+  @Nullable
+  static <T> T[][] copy2DArray(@Nullable T[][] values) {
+    if (values == null) {
+      return null;
+    }
+
+    T[][] copied = Arrays.copyOf(values, values.length);
+    for (int index = 0; index < values.length; index++) {
+      copied[index] = copyArray(values[index]);
+    }
+    return copied;
+  }
+
+  @Nullable
+  static <S, T> T[] convertArray(
+      @Nullable S[] values, Function<S, T> converter, IntFunction<T[]> 
arrayFactory) {
+    if (values == null) {
+      return null;
+    }
+
+    T[] converted = arrayFactory.apply(values.length);
+    for (int index = 0; index < values.length; index++) {
+      converted[index] = values[index] == null ? null : 
converter.apply(values[index]);
+    }
+    return converted;
+  }
+
+  @Nullable
+  static Object readJsonValue(JsonParser parser, JsonToken token) throws 
IOException {
+    switch (token) {
+      case VALUE_NULL:
+        return null;
+      case VALUE_STRING:
+        return parser.getText();
+      case VALUE_TRUE:
+        return Boolean.TRUE;
+      case VALUE_FALSE:
+        return Boolean.FALSE;
+      case VALUE_NUMBER_INT:
+        return parser.getBigIntegerValue();
+      case VALUE_NUMBER_FLOAT:
+        return parser.getDecimalValue();
+      case START_OBJECT:
+        return readJsonObject(parser);
+      case START_ARRAY:
+        return readJsonArray(parser);
+      default:
+        throw JsonMappingException.from(parser, "Unsupported JSON token: " + 
token);
+    }
+  }
+
+  static final class DataTypeSerializer extends JsonSerializer<DataType> {
+
+    @Override
+    public void serialize(DataType value, JsonGenerator generator, 
SerializerProvider serializers)
+        throws IOException {
+      generator.writeString(dataTypeName(value));
+    }
+  }
+
+  static final class DataTypeDeserializer extends JsonDeserializer<DataType> {
+
+    @Override
+    public DataType deserialize(JsonParser parser, DeserializationContext 
context)
+        throws IOException {
+      String value = requireString(parser, "DataType");
+      switch (value) {
+        case "String":
+          return DataType.STRING;
+        case "Integer":
+          return DataType.INTEGER;
+        case "Decimal":
+          return DataType.DECIMAL;
+        case "Float":
+          return DataType.FLOAT;
+        case "Boolean":
+          return DataType.BOOLEAN;
+        case "Date":
+          return DataType.DATE;
+        case "Time":
+          return DataType.TIME;
+        case "DateTime":
+          return DataType.DATE_TIME;
+        case "DateTimeTz":
+          return DataType.DATE_TIME_TZ;
+        case "Opaque":
+          return DataType.OPAQUE;
+        default:
+          throw JsonMappingException.from(
+              parser,
+              "Unknown Semantic Model data type: "
+                  + value
+                  + ". Supported values: "
+                  + supportedDataTypeNames());
+      }
+    }
+  }
+
+  private static Map<String, Object> readJsonObject(JsonParser parser) throws 
IOException {
+    Map<String, Object> values = new LinkedHashMap<>();
+    while (parser.nextToken() != JsonToken.END_OBJECT) {
+      if (!parser.hasToken(JsonToken.FIELD_NAME)) {
+        throw JsonMappingException.from(
+            parser, "Expected a JSON object property name, but found " + 
parser.currentToken());
+      }
+      String name = parser.currentName();
+      values.put(name, readJsonValue(parser, parser.nextToken()));
+    }
+    return values;
+  }
+
+  private static List<Object> readJsonArray(JsonParser parser) throws 
IOException {
+    List<Object> values = new ArrayList<>();
+    while (parser.nextToken() != JsonToken.END_ARRAY) {
+      values.add(readJsonValue(parser, parser.currentToken()));
+    }
+    return values;
+  }
+
+  private static String requireString(JsonParser parser, String type) throws 
IOException {
+    if (!parser.hasToken(JsonToken.VALUE_STRING)) {
+      throw JsonMappingException.from(
+          parser, type + " must be encoded as a string, but found " + 
parser.currentToken());
+    }
+    return parser.getText();
+  }
+
+  private static String dataTypeName(DataType dataType) {
+    switch (dataType) {
+      case STRING:
+        return "String";
+      case INTEGER:
+        return "Integer";
+      case DECIMAL:
+        return "Decimal";
+      case FLOAT:
+        return "Float";
+      case BOOLEAN:
+        return "Boolean";
+      case DATE:
+        return "Date";
+      case TIME:
+        return "Time";
+      case DATE_TIME:
+        return "DateTime";
+      case DATE_TIME_TZ:
+        return "DateTimeTz";
+      case OPAQUE:
+        return "Opaque";
+      default:
+        throw new IllegalArgumentException("Unsupported Semantic Model data 
type: " + dataType);
+    }
+  }
+
+  private static String supportedDataTypeNames() {
+    return Arrays.stream(DataType.values())
+        .map(SemanticDTOUtils::dataTypeName)
+        .collect(Collectors.joining(", "));
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticModelDefinitionDTO.java
 
b/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticModelDefinitionDTO.java
new file mode 100644
index 0000000000..5bbe365803
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticModelDefinitionDTO.java
@@ -0,0 +1,189 @@
+/*
+ * 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.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import javax.annotation.Nullable;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.Metric;
+import org.apache.gravitino.semantic.Relationship;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+
+/** DTO for the complete persisted definition of a Semantic Model. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"aiContext", "datasets", "relationships", "metrics", 
"customExtensions"})
+public class SemanticModelDefinitionDTO {
+
+  @Nullable
+  @JsonProperty("aiContext")
+  private AIContextDTO aiContext;
+
+  @JsonProperty("datasets")
+  @Getter(AccessLevel.NONE)
+  private DatasetDTO[] datasets;
+
+  @Nullable
+  @JsonProperty("relationships")
+  @Getter(AccessLevel.NONE)
+  private RelationshipDTO[] relationships;
+
+  @Nullable
+  @JsonProperty("metrics")
+  @Getter(AccessLevel.NONE)
+  private MetricDTO[] metrics;
+
+  @Nullable
+  @JsonProperty("customExtensions")
+  @Getter(AccessLevel.NONE)
+  private CustomExtensionDTO[] customExtensions;
+
+  @Builder(setterPrefix = "with")
+  private SemanticModelDefinitionDTO(
+      @Nullable AIContextDTO aiContext,
+      DatasetDTO[] datasets,
+      @Nullable RelationshipDTO[] relationships,
+      @Nullable MetricDTO[] metrics,
+      @Nullable CustomExtensionDTO[] customExtensions) {
+    this.aiContext = aiContext;
+    this.datasets = SemanticDTOUtils.copyArray(datasets);
+    this.relationships = SemanticDTOUtils.copyArray(relationships);
+    this.metrics = SemanticDTOUtils.copyArray(metrics);
+    this.customExtensions = SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Returns the datasets in this definition.
+   *
+   * @return A defensive copy of the datasets.
+   */
+  public DatasetDTO[] getDatasets() {
+    return SemanticDTOUtils.copyArray(datasets);
+  }
+
+  /**
+   * Returns the relationships in this definition.
+   *
+   * @return A defensive copy of the relationships, or {@code null} when not 
provided.
+   */
+  @Nullable
+  public RelationshipDTO[] getRelationships() {
+    return SemanticDTOUtils.copyArray(relationships);
+  }
+
+  /**
+   * Returns the metrics in this definition.
+   *
+   * @return A defensive copy of the metrics, or {@code null} when not 
provided.
+   */
+  @Nullable
+  public MetricDTO[] getMetrics() {
+    return SemanticDTOUtils.copyArray(metrics);
+  }
+
+  /**
+   * Returns the custom extensions associated with this definition.
+   *
+   * @return A defensive copy of the custom extensions, or {@code null} when 
not provided.
+   */
+  @Nullable
+  public CustomExtensionDTO[] getCustomExtensions() {
+    return SemanticDTOUtils.copyArray(customExtensions);
+  }
+
+  /**
+   * Creates a persistence DTO from an API Semantic Model definition.
+   *
+   * @param definition The API Semantic Model definition.
+   * @return The persistence DTO.
+   */
+  public static SemanticModelDefinitionDTO 
fromDefinition(SemanticModelDefinition definition) {
+    AIContext sourceAIContext = definition.aiContext();
+    return builder()
+        .withAiContext(sourceAIContext == null ? null : 
AIContextDTO.fromAIContext(sourceAIContext))
+        .withDatasets(
+            SemanticDTOUtils.convertArray(
+                definition.datasets(), DatasetDTO::fromDataset, 
DatasetDTO[]::new))
+        .withRelationships(
+            SemanticDTOUtils.convertArray(
+                definition.relationships(),
+                RelationshipDTO::fromRelationship,
+                RelationshipDTO[]::new))
+        .withMetrics(
+            SemanticDTOUtils.convertArray(
+                definition.metrics(), MetricDTO::fromMetric, MetricDTO[]::new))
+        .withCustomExtensions(
+            SemanticDTOUtils.convertArray(
+                definition.customExtensions(),
+                CustomExtensionDTO::fromCustomExtension,
+                CustomExtensionDTO[]::new))
+        .build();
+  }
+
+  /**
+   * Converts this persistence DTO to an API Semantic Model definition.
+   *
+   * @return The API Semantic Model definition.
+   */
+  public SemanticModelDefinition toDefinition() {
+    Dataset[] convertedDatasets =
+        SemanticDTOUtils.convertArray(datasets, DatasetDTO::toDataset, 
Dataset[]::new);
+    Relationship[] convertedRelationships =
+        SemanticDTOUtils.convertArray(
+            relationships, RelationshipDTO::toRelationship, 
Relationship[]::new);
+    Metric[] convertedMetrics =
+        SemanticDTOUtils.convertArray(metrics, MetricDTO::toMetric, 
Metric[]::new);
+    CustomExtension[] convertedExtensions =
+        SemanticDTOUtils.convertArray(
+            customExtensions, CustomExtensionDTO::toCustomExtension, 
CustomExtension[]::new);
+    return SemanticModelDefinition.builder()
+        .withAIContext(aiContext == null ? null : aiContext.toAIContext())
+        .withDatasets(convertedDatasets)
+        .withRelationships(convertedRelationships)
+        .withMetrics(convertedMetrics)
+        .withCustomExtensions(convertedExtensions)
+        .build();
+  }
+
+  /** Builder for {@link SemanticModelDefinitionDTO}. */
+  public static class SemanticModelDefinitionDTOBuilder {
+
+    /**
+     * Sets the optional AI context.
+     *
+     * @param aiContext The AI context DTO.
+     * @return This builder.
+     */
+    public SemanticModelDefinitionDTOBuilder withAiContext(@Nullable 
AIContextDTO aiContext) {
+      this.aiContext = aiContext;
+      return this;
+    }
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticDTOUtils.java
 
b/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticDTOUtils.java
new file mode 100644
index 0000000000..32b2162842
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticDTOUtils.java
@@ -0,0 +1,171 @@
+/*
+ * 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.dto.semantic;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.semantic.DataType;
+import org.junit.jupiter.api.Test;
+
+public class TestSemanticDTOUtils {
+
+  @Test
+  public void testConvertArray() {
+    assertNull(
+        SemanticDTOUtils.convertArray(null, (Integer value) -> 
value.toString(), String[]::new));
+    assertArrayEquals(
+        new String[0],
+        SemanticDTOUtils.convertArray(
+            new Integer[0], (Integer value) -> value.toString(), 
String[]::new));
+    assertArrayEquals(
+        new String[] {"1", null, "3"},
+        SemanticDTOUtils.convertArray(new Integer[] {1, null, 3}, 
Object::toString, String[]::new));
+  }
+
+  @Test
+  public void testReadJsonValue() throws IOException {
+    String json =
+        "{\"text\":\"value\","
+            + "\"trueValue\":true,"
+            + "\"falseValue\":false,"
+            + "\"integer\":123456789012345678901234567890,"
+            + "\"decimal\":0.123456789012345678901234567890,"
+            + "\"nullValue\":null,"
+            + "\"array\":[1,\"two\",false,null],"
+            + "\"object\":{\"first\":1,\"second\":2}}";
+
+    try (JsonParser parser = new 
ObjectMapper().getFactory().createParser(json)) {
+      assertEquals(JsonToken.START_OBJECT, parser.nextToken());
+      Object value = SemanticDTOUtils.readJsonValue(parser, 
parser.currentToken());
+
+      assertTrue(value instanceof Map);
+      Map<?, ?> values = (Map<?, ?>) value;
+      assertEquals(
+          List.of(
+              "text",
+              "trueValue",
+              "falseValue",
+              "integer",
+              "decimal",
+              "nullValue",
+              "array",
+              "object"),
+          new ArrayList<>(values.keySet()));
+      assertEquals("value", values.get("text"));
+      assertEquals(Boolean.TRUE, values.get("trueValue"));
+      assertEquals(Boolean.FALSE, values.get("falseValue"));
+      assertEquals(new BigInteger("123456789012345678901234567890"), 
values.get("integer"));
+      assertEquals(new BigDecimal("0.123456789012345678901234567890"), 
values.get("decimal"));
+      assertNull(values.get("nullValue"));
+
+      List<?> array = (List<?>) values.get("array");
+      assertEquals(4, array.size());
+      assertEquals(BigInteger.ONE, array.get(0));
+      assertEquals("two", array.get(1));
+      assertEquals(Boolean.FALSE, array.get(2));
+      assertNull(array.get(3));
+
+      Map<?, ?> object = (Map<?, ?>) values.get("object");
+      assertEquals(List.of("first", "second"), new 
ArrayList<>(object.keySet()));
+      assertEquals(BigInteger.ONE, object.get("first"));
+      assertEquals(BigInteger.TWO, object.get("second"));
+    }
+  }
+
+  @Test
+  public void testReadJsonValueRejectsUnsupportedToken() throws IOException {
+    try (JsonParser parser = new 
ObjectMapper().getFactory().createParser("{\"value\":1}")) {
+      assertEquals(JsonToken.START_OBJECT, parser.nextToken());
+      assertEquals(JsonToken.FIELD_NAME, parser.nextToken());
+
+      JsonMappingException exception =
+          assertThrows(
+              JsonMappingException.class,
+              () -> SemanticDTOUtils.readJsonValue(parser, 
parser.currentToken()));
+      assertEquals("Unsupported JSON token: FIELD_NAME", 
exception.getOriginalMessage());
+    }
+  }
+
+  @Test
+  public void testDataTypeSerializationAndDeserialization() throws IOException 
{
+    ObjectMapper mapper = dataTypeMapper();
+    for (Map.Entry<DataType, String> entry : dataTypeNames().entrySet()) {
+      assertEquals("\"" + entry.getValue() + "\"", 
mapper.writeValueAsString(entry.getKey()));
+      assertEquals(
+          entry.getKey(), mapper.readValue("\"" + entry.getValue() + "\"", 
DataType.class));
+    }
+  }
+
+  @Test
+  public void testDataTypeDeserializationRejectsInvalidValues() {
+    ObjectMapper mapper = dataTypeMapper();
+
+    JsonMappingException unknownValue =
+        assertThrows(
+            JsonMappingException.class, () -> mapper.readValue("\"string\"", 
DataType.class));
+    assertEquals(
+        "Unknown Semantic Model data type: string. Supported values: "
+            + String.join(", ", dataTypeNames().values()),
+        unknownValue.getOriginalMessage());
+
+    JsonMappingException nonString =
+        assertThrows(JsonMappingException.class, () -> mapper.readValue("42", 
DataType.class));
+    assertEquals(
+        "DataType must be encoded as a string, but found VALUE_NUMBER_INT",
+        nonString.getOriginalMessage());
+  }
+
+  private static ObjectMapper dataTypeMapper() {
+    SimpleModule module = new SimpleModule();
+    module.addSerializer(DataType.class, new 
SemanticDTOUtils.DataTypeSerializer());
+    module.addDeserializer(DataType.class, new 
SemanticDTOUtils.DataTypeDeserializer());
+    return new ObjectMapper().registerModule(module);
+  }
+
+  private static Map<DataType, String> dataTypeNames() {
+    Map<DataType, String> names = new LinkedHashMap<>();
+    names.put(DataType.STRING, "String");
+    names.put(DataType.INTEGER, "Integer");
+    names.put(DataType.DECIMAL, "Decimal");
+    names.put(DataType.FLOAT, "Float");
+    names.put(DataType.BOOLEAN, "Boolean");
+    names.put(DataType.DATE, "Date");
+    names.put(DataType.TIME, "Time");
+    names.put(DataType.DATE_TIME, "DateTime");
+    names.put(DataType.DATE_TIME_TZ, "DateTimeTz");
+    names.put(DataType.OPAQUE, "Opaque");
+    return names;
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticModelDefinitionDTO.java
 
b/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticModelDefinitionDTO.java
new file mode 100644
index 0000000000..0b5256a964
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticModelDefinitionDTO.java
@@ -0,0 +1,545 @@
+/*
+ * 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.dto.semantic;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.AIContextObject;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.DataType;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.DialectExpression;
+import org.apache.gravitino.semantic.Dialects;
+import org.apache.gravitino.semantic.Dimension;
+import org.apache.gravitino.semantic.Expression;
+import org.apache.gravitino.semantic.Field;
+import org.apache.gravitino.semantic.Metric;
+import org.apache.gravitino.semantic.Relationship;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.junit.jupiter.api.Test;
+
+public class TestSemanticModelDefinitionDTO {
+
+  private final ObjectMapper objectMapper = JsonUtils.objectMapper();
+  private final ObjectMapper persistenceMapper = JsonUtils.anyFieldMapper();
+
+  @Test
+  public void testDefinitionConversionAndJsonRoundTrip() throws 
JsonProcessingException {
+    SemanticModelDefinition definition = definition();
+    SemanticModelDefinitionDTO dto = 
SemanticModelDefinitionDTO.fromDefinition(definition);
+
+    String json = persistenceMapper.writeValueAsString(dto);
+    JsonNode root = persistenceMapper.readTree(json);
+    JsonNode dataset = root.path("datasets").get(0);
+    JsonNode field = dataset.path("fields").get(0);
+    JsonNode relationship = root.path("relationships").get(0);
+    JsonNode metric = root.path("metrics").get(0);
+
+    assertTrue(root.has("aiContext"));
+    assertTrue(root.has("customExtensions"));
+    assertFalse(root.has("ai_context"));
+    assertFalse(root.has("custom_extensions"));
+    assertEquals(List.of("sales", "mart"), 
stringValues(dataset.path("source").path("namespace")));
+    assertEquals("orders", dataset.path("source").path("name").textValue());
+    assertTrue(dataset.has("primaryKey"));
+    assertTrue(dataset.has("uniqueKeys"));
+    assertTrue(dataset.has("aiContext"));
+    assertTrue(dataset.has("customExtensions"));
+    assertFalse(dataset.has("primary_key"));
+    assertFalse(dataset.has("unique_keys"));
+    assertFalse(dataset.has("ai_context"));
+    assertFalse(dataset.has("custom_extensions"));
+    assertEquals("DateTimeTz", field.path("datatype").textValue());
+    assertTrue(field.path("dimension").path("isTime").booleanValue());
+    assertFalse(field.path("dimension").has("is_time"));
+    assertTrue(field.has("customExtensions"));
+    assertFalse(field.has("custom_extensions"));
+    assertEquals(
+        "ANSI_SQL", 
field.path("expression").path("dialects").get(0).path("dialect").textValue());
+    assertTrue(relationship.has("fromColumns"));
+    assertTrue(relationship.has("toColumns"));
+    assertTrue(relationship.has("customExtensions"));
+    assertFalse(relationship.has("from_columns"));
+    assertFalse(relationship.has("to_columns"));
+    assertFalse(relationship.has("custom_extensions"));
+    assertTrue(metric.has("customExtensions"));
+    assertFalse(metric.has("custom_extensions"));
+    assertEquals("example", 
root.path("customExtensions").get(0).path("vendorName").textValue());
+
+    JsonNode aiContext = root.path("aiContext");
+    assertEquals(
+        List.of(
+            "instructions", "synonyms", "examples", "priority", 
"semantic_hints", "nullable_hint"),
+        fieldNames(aiContext));
+    assertEquals(List.of("first", "second"), 
fieldNames(aiContext.path("semantic_hints")));
+    assertTrue(aiContext.path("nullable_hint").isNull());
+
+    SemanticModelDefinitionDTO deserialized =
+        persistenceMapper.readValue(json, SemanticModelDefinitionDTO.class);
+    assertEquals(dto, deserialized);
+    assertEquals(definition, deserialized.toDefinition());
+    assertEquals(json, persistenceMapper.writeValueAsString(deserialized));
+  }
+
+  @Test
+  public void testAIContextUnionAndUnknownPropertyOrder() throws 
JsonProcessingException {
+    AIContextDTO textContext = AIContextDTO.fromAIContext(AIContext.of(""));
+    String textJson = objectMapper.writeValueAsString(textContext);
+    assertEquals("\"\"", textJson);
+    assertEquals(
+        AIContext.of(""), objectMapper.readValue(textJson, 
AIContextDTO.class).toAIContext());
+
+    String objectJson =
+        "{\"instructions\":\"Use governed data\","
+            + "\"first_unknown\":{\"alpha\":1,\"beta\":2},"
+            + "\"synonyms\":[\"governed\"],"
+            + "\"second_unknown\":[true,null],"
+            + "\"precise\":0.123456789012345678901234567890}";
+    AIContextDTO objectContext = objectMapper.readValue(objectJson, 
AIContextDTO.class);
+
+    assertNull(objectContext.getText());
+    assertEquals(
+        List.of("first_unknown", "second_unknown", "precise"),
+        new 
ArrayList<>(objectContext.getObject().getAdditionalProperties().keySet()));
+    assertEquals(
+        new BigDecimal("0.123456789012345678901234567890"),
+        objectContext.getObject().getAdditionalProperties().get("precise"));
+    assertEquals(
+        List.of("alpha", "beta"),
+        new ArrayList<>(
+            ((Map<?, ?>) 
objectContext.getObject().getAdditionalProperties().get("first_unknown"))
+                .keySet()));
+    assertEquals(
+        BigInteger.ONE,
+        ((Map<?, ?>) 
objectContext.getObject().getAdditionalProperties().get("first_unknown"))
+            .get("alpha"));
+
+    AIContextDTO converted = 
AIContextDTO.fromAIContext(objectContext.toAIContext());
+    String convertedJson = objectMapper.writeValueAsString(converted);
+    AIContextDTO roundTripped = objectMapper.readValue(convertedJson, 
AIContextDTO.class);
+    assertEquals(
+        List.of("first_unknown", "second_unknown", "precise"),
+        new 
ArrayList<>(converted.getObject().getAdditionalProperties().keySet()));
+    assertEquals(
+        List.of("instructions", "synonyms", "first_unknown", "second_unknown", 
"precise"),
+        fieldNames(objectMapper.readTree(convertedJson)));
+    assertEquals(
+        objectContext.getObject().getAdditionalProperties(),
+        converted.getObject().getAdditionalProperties());
+    assertEquals(
+        new BigDecimal("0.123456789012345678901234567890"),
+        roundTripped.getObject().getAdditionalProperties().get("precise"));
+
+    assertThrows(
+        JsonProcessingException.class, () -> objectMapper.readValue("42", 
AIContextDTO.class));
+  }
+
+  @Test
+  public void testAIContextObjectExplicitNullHandling() throws 
JsonProcessingException {
+    AIContextDTO explicitNull =
+        objectMapper.readValue(
+            
"{\"instructions\":null,\"synonyms\":null,\"examples\":null,\"unknown\":null}",
+            AIContextDTO.class);
+
+    assertNull(explicitNull.getObject().getInstructions());
+    assertNull(explicitNull.getObject().getSynonyms());
+    assertNull(explicitNull.getObject().getExamples());
+    
assertTrue(explicitNull.getObject().getAdditionalProperties().containsKey("unknown"));
+    
assertNull(explicitNull.getObject().getAdditionalProperties().get("unknown"));
+
+    JsonNode serialized = 
objectMapper.readTree(objectMapper.writeValueAsString(explicitNull));
+    assertFalse(serialized.has("instructions"));
+    assertFalse(serialized.has("synonyms"));
+    assertFalse(serialized.has("examples"));
+    assertTrue(serialized.path("unknown").isNull());
+
+    AIContextDTO emptyArrays =
+        objectMapper.readValue("{\"synonyms\":[],\"examples\":[]}", 
AIContextDTO.class);
+    assertArrayEquals(new String[0], emptyArrays.getObject().getSynonyms());
+    assertArrayEquals(new String[0], emptyArrays.getObject().getExamples());
+
+    JsonMappingException nullElement =
+        assertThrows(
+            JsonMappingException.class,
+            () -> objectMapper.readValue("{\"synonyms\":[null]}", 
AIContextDTO.class));
+    assertEquals("synonyms must be a string", 
nullElement.getOriginalMessage());
+
+    JsonMappingException invalidInstructions =
+        assertThrows(
+            JsonMappingException.class,
+            () -> objectMapper.readValue("{\"instructions\":42}", 
AIContextDTO.class));
+    assertEquals("instructions must be a string", 
invalidInstructions.getOriginalMessage());
+  }
+
+  @Test
+  public void testAbsentAndEmptyArraysRemainDistinct() throws 
JsonProcessingException {
+    Dataset absentDataset =
+        Dataset.builder()
+            .withName("absent")
+            .withSource(NameIdentifier.of("sales", "mart", "absent"))
+            .build();
+    SemanticModelDefinition absentDefinition =
+        SemanticModelDefinition.builder().withDatasets(new Dataset[] 
{absentDataset}).build();
+    SemanticModelDefinitionDTO absent = 
SemanticModelDefinitionDTO.fromDefinition(absentDefinition);
+    JsonNode absentJson = 
objectMapper.readTree(objectMapper.writeValueAsString(absent));
+    JsonNode absentDatasetJson = absentJson.path("datasets").get(0);
+
+    assertFalse(absentDatasetJson.has("fields"));
+    assertFalse(absentJson.has("relationships"));
+    assertEquals(absentDefinition, absent.toDefinition());
+
+    Dataset emptyDataset =
+        Dataset.builder()
+            .withName("empty")
+            .withSource(NameIdentifier.of("sales", "mart", "empty"))
+            .withPrimaryKey(new String[0])
+            .withUniqueKeys(new String[0][])
+            .withFields(new Field[0])
+            .withCustomExtensions(new CustomExtension[0])
+            .build();
+    SemanticModelDefinition emptyDefinition =
+        SemanticModelDefinition.builder()
+            .withDatasets(new Dataset[] {emptyDataset})
+            .withRelationships(new Relationship[0])
+            .withMetrics(new Metric[0])
+            .withCustomExtensions(new CustomExtension[0])
+            .build();
+    SemanticModelDefinitionDTO empty = 
SemanticModelDefinitionDTO.fromDefinition(emptyDefinition);
+    JsonNode emptyJson = 
objectMapper.readTree(objectMapper.writeValueAsString(empty));
+
+    assertArrayEquals(new Relationship[0], 
empty.toDefinition().relationships());
+    assertTrue(emptyJson.path("relationships").isEmpty());
+    assertTrue(emptyJson.path("datasets").get(0).path("fields").isEmpty());
+    assertEquals(emptyDefinition, empty.toDefinition());
+  }
+
+  @Test
+  public void testExactDataTypeJsonValuesAndOpenDialect() throws 
JsonProcessingException {
+    Map<DataType, String> dataTypeValues = new LinkedHashMap<>();
+    dataTypeValues.put(DataType.STRING, "String");
+    dataTypeValues.put(DataType.INTEGER, "Integer");
+    dataTypeValues.put(DataType.DECIMAL, "Decimal");
+    dataTypeValues.put(DataType.FLOAT, "Float");
+    dataTypeValues.put(DataType.BOOLEAN, "Boolean");
+    dataTypeValues.put(DataType.DATE, "Date");
+    dataTypeValues.put(DataType.TIME, "Time");
+    dataTypeValues.put(DataType.DATE_TIME, "DateTime");
+    dataTypeValues.put(DataType.DATE_TIME_TZ, "DateTimeTz");
+    dataTypeValues.put(DataType.OPAQUE, "Opaque");
+
+    for (Map.Entry<DataType, String> entry : dataTypeValues.entrySet()) {
+      MetricDTO metric =
+          MetricDTO.builder()
+              .withName("metric")
+              
.withExpression(ExpressionDTO.fromExpression(expression("value")))
+              .withDatatype(entry.getKey())
+              .build();
+      String json = objectMapper.writeValueAsString(metric);
+      assertEquals(entry.getValue(), 
objectMapper.readTree(json).path("datatype").textValue());
+      assertEquals(entry.getKey(), objectMapper.readValue(json, 
MetricDTO.class).getDatatype());
+    }
+
+    DialectExpressionDTO dialectExpression =
+        
DialectExpressionDTO.builder().withDialect("TRINO").withExpression("value").build();
+    String json = objectMapper.writeValueAsString(dialectExpression);
+    assertEquals("TRINO", 
objectMapper.readTree(json).path("dialect").textValue());
+    assertEquals("TRINO", objectMapper.readValue(json, 
DialectExpressionDTO.class).getDialect());
+  }
+
+  @Test
+  public void testUnknownPropertiesAreOnlyAllowedInAIContext() {
+    String dataset =
+        "{\"name\":\"orders\","
+            + 
"\"source\":{\"namespace\":[\"sales\",\"mart\"],\"name\":\"orders\"},"
+            + "\"unknown\":true}";
+
+    assertThrows(
+        JsonProcessingException.class, () -> objectMapper.readValue(dataset, 
DatasetDTO.class));
+  }
+
+  @Test
+  public void testAIContextObjectDTOIsDeeplyImmutable() {
+    String[] synonyms = {"sales"};
+    String[] examples = {"Revenue by month"};
+    List<Object> hints = new ArrayList<>();
+    hints.add("certified");
+    Map<String, Object> nested = new LinkedHashMap<>();
+    nested.put("hints", hints);
+    Map<String, Object> additionalProperties = new LinkedHashMap<>();
+    additionalProperties.put("semantic", nested);
+
+    AIContextObjectDTO dto =
+        AIContextObjectDTO.builder()
+            .withSynonyms(synonyms)
+            .withExamples(examples)
+            .withAdditionalProperties(additionalProperties)
+            .build();
+    int originalHashCode = dto.hashCode();
+
+    synonyms[0] = "changed";
+    examples[0] = "changed";
+    hints.add("changed");
+    nested.put("changed", true);
+    additionalProperties.put("changed", true);
+
+    assertArrayEquals(new String[] {"sales"}, dto.getSynonyms());
+    assertArrayEquals(new String[] {"Revenue by month"}, dto.getExamples());
+    Map<?, ?> immutableNested = (Map<?, ?>) 
dto.getAdditionalProperties().get("semantic");
+    List<?> immutableHints = (List<?>) immutableNested.get("hints");
+    assertEquals(List.of("certified"), immutableHints);
+    assertEquals(originalHashCode, dto.hashCode());
+
+    dto.getSynonyms()[0] = "changed";
+    dto.getExamples()[0] = "changed";
+    assertArrayEquals(new String[] {"sales"}, dto.getSynonyms());
+    assertArrayEquals(new String[] {"Revenue by month"}, dto.getExamples());
+    assertThrows(UnsupportedOperationException.class, () -> 
dto.getAdditionalProperties().clear());
+    assertThrows(UnsupportedOperationException.class, immutableNested::clear);
+    assertThrows(UnsupportedOperationException.class, immutableHints::clear);
+    assertEquals(originalHashCode, dto.hashCode());
+  }
+
+  @Test
+  public void testSemanticDTOArraysAreDefensivelyCopied() {
+    DialectExpressionDTO dialect =
+        
DialectExpressionDTO.builder().withDialect("ANSI_SQL").withExpression("value").build();
+    DialectExpressionDTO[] dialects = {dialect};
+    ExpressionDTO expression = 
ExpressionDTO.builder().withDialects(dialects).build();
+
+    CustomExtensionDTO extension =
+        
CustomExtensionDTO.builder().withVendorName("example").withData("{}").build();
+    CustomExtensionDTO[] fieldExtensions = {extension};
+    FieldDTO field =
+        FieldDTO.builder()
+            .withName("id")
+            .withExpression(expression)
+            .withCustomExtensions(fieldExtensions)
+            .build();
+
+    CustomExtensionDTO[] metricExtensions = {extension};
+    MetricDTO metric =
+        MetricDTO.builder()
+            .withName("count")
+            .withExpression(expression)
+            .withCustomExtensions(metricExtensions)
+            .build();
+
+    String[] fromColumns = {"customer_id"};
+    String[] toColumns = {"id"};
+    CustomExtensionDTO[] relationshipExtensions = {extension};
+    RelationshipDTO relationship =
+        RelationshipDTO.builder()
+            .withName("orders_to_customers")
+            .withFrom("orders")
+            .withTo("customers")
+            .withFromColumns(fromColumns)
+            .withToColumns(toColumns)
+            .withCustomExtensions(relationshipExtensions)
+            .build();
+
+    String[] primaryKey = {"id"};
+    String[][] uniqueKeys = {{"external_id", "source"}};
+    FieldDTO[] fields = {field};
+    CustomExtensionDTO[] datasetExtensions = {extension};
+    DatasetDTO dataset =
+        DatasetDTO.builder()
+            .withName("orders")
+            .withSource(NameIdentifier.of("sales", "orders"))
+            .withPrimaryKey(primaryKey)
+            .withUniqueKeys(uniqueKeys)
+            .withFields(fields)
+            .withCustomExtensions(datasetExtensions)
+            .build();
+
+    DatasetDTO[] datasets = {dataset};
+    RelationshipDTO[] relationships = {relationship};
+    MetricDTO[] metrics = {metric};
+    CustomExtensionDTO[] definitionExtensions = {extension};
+    SemanticModelDefinitionDTO definition =
+        SemanticModelDefinitionDTO.builder()
+            .withDatasets(datasets)
+            .withRelationships(relationships)
+            .withMetrics(metrics)
+            .withCustomExtensions(definitionExtensions)
+            .build();
+
+    assertDefensiveCopy(dialects, expression::getDialects);
+    assertDefensiveCopy(fieldExtensions, field::getCustomExtensions);
+    assertDefensiveCopy(metricExtensions, metric::getCustomExtensions);
+    assertDefensiveCopy(fromColumns, relationship::getFromColumns);
+    assertDefensiveCopy(toColumns, relationship::getToColumns);
+    assertDefensiveCopy(relationshipExtensions, 
relationship::getCustomExtensions);
+    assertDefensiveCopy(primaryKey, dataset::getPrimaryKey);
+    assertDefensiveCopy(fields, dataset::getFields);
+    assertDefensiveCopy(datasetExtensions, dataset::getCustomExtensions);
+    assertDefensiveCopy(datasets, definition::getDatasets);
+    assertDefensiveCopy(relationships, definition::getRelationships);
+    assertDefensiveCopy(metrics, definition::getMetrics);
+    assertDefensiveCopy(definitionExtensions, definition::getCustomExtensions);
+
+    uniqueKeys[0][0] = "changed";
+    assertArrayEquals(new String[] {"external_id", "source"}, 
dataset.getUniqueKeys()[0]);
+    String[][] returnedUniqueKeys = dataset.getUniqueKeys();
+    returnedUniqueKeys[0][0] = "changed";
+    assertArrayEquals(new String[] {"external_id", "source"}, 
dataset.getUniqueKeys()[0]);
+  }
+
+  private static SemanticModelDefinition definition() {
+    Map<String, Object> semanticHints = new LinkedHashMap<>();
+    semanticHints.put("first", "prefer certified metrics");
+    semanticHints.put("second", List.of("month", "region"));
+    Map<String, Object> additionalProperties = new LinkedHashMap<>();
+    additionalProperties.put("priority", 1);
+    additionalProperties.put("semantic_hints", semanticHints);
+    additionalProperties.put("nullable_hint", null);
+
+    AIContextObject aiContextObject =
+        AIContextObject.builder()
+            .withInstructions("Use governed data")
+            .withSynonyms(new String[] {"sales", "revenue"})
+            .withExamples(new String[] {"Revenue by month"})
+            .withAdditionalProperties(additionalProperties)
+            .build();
+    CustomExtension extension =
+        CustomExtension.builder()
+            .withVendorName("example")
+            .withData("{\"semantic_type\":\"money\"}")
+            .build();
+    Field orderTime =
+        Field.builder()
+            .withName("order_time")
+            .withExpression(expression("order_time"))
+            .withDimension(Dimension.builder().withIsTime(true).build())
+            .withLabel("Order time")
+            .withDescription("Order creation time")
+            .withDatatype(DataType.DATE_TIME_TZ)
+            .withAIContext(AIContext.of("Use the business timezone"))
+            .withCustomExtensions(new CustomExtension[] {extension})
+            .build();
+    Field amount =
+        Field.builder()
+            .withName("amount")
+            .withExpression(expression("order_amount"))
+            .withDimension(Dimension.builder().withIsTime(false).build())
+            .withDatatype(DataType.DECIMAL)
+            .build();
+    Dataset orders =
+        Dataset.builder()
+            .withName("orders")
+            .withSource(NameIdentifier.of("sales", "mart", "orders"))
+            .withPrimaryKey(new String[] {"order_id"})
+            .withUniqueKeys(new String[][] {{"order_id"}, {"external_id", 
"source_system"}})
+            .withDescription("Governed orders")
+            .withAIContext(AIContext.of(aiContextObject))
+            .withFields(new Field[] {orderTime, amount})
+            .withCustomExtensions(new CustomExtension[] {extension})
+            .build();
+    Dataset customers =
+        Dataset.builder()
+            .withName("customers")
+            .withSource(NameIdentifier.of("sales", "mart", "customers"))
+            .build();
+    Relationship relationship =
+        Relationship.builder()
+            .withName("orders_to_customers")
+            .withFrom("orders")
+            .withTo("customers")
+            .withFromColumns(new String[] {"customer_id"})
+            .withToColumns(new String[] {"id"})
+            .withAIContext(AIContext.of("Join orders to the canonical 
customer"))
+            .withCustomExtensions(new CustomExtension[] {extension})
+            .build();
+    Metric revenue =
+        Metric.builder()
+            .withName("revenue")
+            .withExpression(expression("SUM(order_amount)"))
+            .withDescription("Total recognized revenue")
+            .withDatatype(DataType.DECIMAL)
+            .withAIContext(AIContext.of("Use for booked revenue"))
+            .withCustomExtensions(new CustomExtension[] {extension})
+            .build();
+
+    return SemanticModelDefinition.builder()
+        .withAIContext(AIContext.of(aiContextObject))
+        .withDatasets(new Dataset[] {orders, customers})
+        .withRelationships(new Relationship[] {relationship})
+        .withMetrics(new Metric[] {revenue})
+        .withCustomExtensions(new CustomExtension[] {extension})
+        .build();
+  }
+
+  private static Expression expression(String value) {
+    return Expression.builder()
+        .withDialects(
+            new DialectExpression[] {
+              DialectExpression.builder()
+                  .withDialect(Dialects.ANSI_SQL)
+                  .withExpression(value)
+                  .build(),
+              DialectExpression.builder()
+                  .withDialect(Dialects.BIGQUERY)
+                  .withExpression(value)
+                  .build()
+            })
+        .build();
+  }
+
+  private static <T> void assertDefensiveCopy(T[] source, Supplier<T[]> 
getter) {
+    T expected = source[0];
+    source[0] = null;
+    assertEquals(expected, getter.get()[0]);
+
+    T[] returned = getter.get();
+    returned[0] = null;
+    assertEquals(expected, getter.get()[0]);
+  }
+
+  private static List<String> fieldNames(JsonNode object) {
+    List<String> names = new ArrayList<>();
+    Iterator<String> fields = object.fieldNames();
+    fields.forEachRemaining(names::add);
+    return names;
+  }
+
+  private static List<String> stringValues(JsonNode array) {
+    List<String> values = new ArrayList<>();
+    array.forEach(value -> values.add(value.textValue()));
+    return values;
+  }
+}

Reply via email to