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

mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new bb634defdc [#12927] fix(core): Enforce writable data types for tables, 
views and expressions (#14318)
bb634defdc is described below

commit bb634defdc5663a8124be5fca1c8d9e52ac096ac
Author: mchades <[email protected]>
AuthorDate: Fri Oct 30 14:18:04 2026 +0800

    [#12927] fix(core): Enforce writable data types for tables, views and 
expressions (#14318)
    
    ### What changes were proposed in this pull request?
    
    Apply shared recursive writable-type validation to table, view, function
    and partition writes, including literal types in defaults and
    expressions. Validate REST requests before DTO conversion and align the
    OpenAPI request schemas. Include update indices in the new writable-type
    and expression validation errors.
    
    ### Why are the changes needed?
    
    Read-compatible deserialization can produce `UnparsedType`, which must
    not enter newly supplied types or expressions. Keep write validation
    separate from catalog type support and metadata import/refresh.
    
    Fixes #12927
    
    ### Does this PR introduce _any_ user-facing change?
    
    New or replaced types must be recognized native types or explicit,
    nonblank `ExternalType` values, including nested types. Existing
    metadata remains readable; import and load-time refresh remain
    read-compatible. Changes that do not resubmit types, such as renames and
    property updates, remain supported. View replacement validates all
    supplied column and literal types, including unchanged ones, even when
    only the comment or SQL representation changes. SQL `UnparsedExpression`
    defaults remain supported.
    
    ### How was this patch tested?
    
    - 374 focused Java tests passed across common, core, server and Java
    client modules (`-PskipITs`).
    - 203 OpenAPI contract checks, including 68 view checks, and
    `:docs:build` passed.
    - Scoped Spotless formatting, API/common Javadoc and `git diff --check`
    passed.
    - Docker integration tests were not run.
---
 .../java/org/apache/gravitino/rel/ViewChange.java  |   7 +
 .../dto/requests/AddPartitionsRequest.java         |   2 +
 .../dto/requests/FunctionDataTypeValidator.java    |  75 +---
 .../gravitino/dto/requests/TableCreateRequest.java |   4 +
 .../gravitino/dto/requests/TableUpdateRequest.java |  85 +++-
 .../dto/requests/TableUpdatesRequest.java          |   4 +-
 .../gravitino/dto/requests/ViewCreateRequest.java  |   2 +
 .../gravitino/dto/requests/ViewUpdateRequest.java  |  41 +-
 .../gravitino/dto/requests/ViewUpdatesRequest.java |   4 +-
 .../gravitino/utils/WritableDataTypeValidator.java | 314 ++++++++++++++
 .../dto/requests/TestWritableDataTypeRequests.java | 370 ++++++++++++++++
 .../utils/TestWritableDataTypeValidator.java       | 246 +++++++++++
 .../catalog/FunctionOperationDispatcher.java       |  11 +
 .../catalog/PartitionOperationDispatcher.java      |   2 +
 .../catalog/TableOperationDispatcher.java          |   5 +
 .../gravitino/catalog/ViewOperationDispatcher.java |  10 +
 .../catalog/TestFunctionOperationDispatcher.java   |  10 +-
 .../catalog/TestWritableDataTypeDispatchers.java   | 472 +++++++++++++++++++++
 .../storage/relational/utils/TestPOConverters.java |  33 ++
 docs/expression.md                                 |  11 +-
 docs/open-api/expression.yaml                      |  54 ++-
 docs/open-api/functions.yaml                       |   2 +-
 docs/open-api/partitioning.yaml                    |  58 ++-
 docs/open-api/partitions.yaml                      |  46 +-
 docs/open-api/tables.yaml                          |  72 +++-
 docs/open-api/views.yaml                           |  11 +-
 docs/tables-and-views.md                           |   9 +
 .../server/web/rest/TestFunctionOperations.java    |  25 ++
 .../server/web/rest/TestPartitionOperations.java   |  22 +
 .../server/web/rest/TestTableOperations.java       |  67 +++
 .../server/web/rest/TestViewOperations.java        |  48 +++
 31 files changed, 2005 insertions(+), 117 deletions(-)

diff --git a/api/src/main/java/org/apache/gravitino/rel/ViewChange.java 
b/api/src/main/java/org/apache/gravitino/rel/ViewChange.java
index 996ddf42a2..c00c5753c3 100644
--- a/api/src/main/java/org/apache/gravitino/rel/ViewChange.java
+++ b/api/src/main/java/org/apache/gravitino/rel/ViewChange.java
@@ -75,6 +75,13 @@ public interface ViewChange {
    * and defaults are discarded. Callers that only intend to change a subset 
of the body should read
    * the current values first and pass them back unchanged.
    *
+   * <p>All supplied column types and literal types in defaults, including 
nested types and types
+   * copied unchanged from the current view, must be recognized Gravitino 
types or explicit {@code
+   * ExternalType} values with nonblank catalog strings. {@code UnparsedType} 
is not accepted, even
+   * when only the comment or another part of the body changes. Operations 
that do not resubmit
+   * types, such as renaming a view or updating its properties, remain 
supported for existing views
+   * with unresolved types.
+   *
    * @param columns The new output columns of the view.
    * @param representations The new representations of the view. At least one 
representation is
    *     expected.
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/AddPartitionsRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/AddPartitionsRequest.java
index dd9552c629..025d5136ad 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/AddPartitionsRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/AddPartitionsRequest.java
@@ -27,6 +27,7 @@ import lombok.ToString;
 import lombok.extern.jackson.Jacksonized;
 import org.apache.gravitino.dto.rel.partitions.PartitionDTO;
 import org.apache.gravitino.rest.RESTRequest;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 
 /** Request to add partitions to a table. */
 @Getter
@@ -58,5 +59,6 @@ public class AddPartitionsRequest implements RESTRequest {
     Preconditions.checkArgument(partitions != null, "partitions must not be 
null");
     Preconditions.checkArgument(
         partitions.length == 1, "Haven't yet implemented multiple partitions");
+    WritableDataTypeValidator.validatePartition(partitions[0], 
"partitions[0]");
   }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/FunctionDataTypeValidator.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/FunctionDataTypeValidator.java
index 23fd3aad6b..566eb63d35 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/FunctionDataTypeValidator.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/FunctionDataTypeValidator.java
@@ -19,12 +19,10 @@
 package org.apache.gravitino.dto.requests;
 
 import com.google.common.base.Preconditions;
-import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.dto.function.FunctionColumnDTO;
 import org.apache.gravitino.dto.function.FunctionDefinitionDTO;
 import org.apache.gravitino.dto.function.FunctionParamDTO;
-import org.apache.gravitino.rel.types.Type;
-import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 
 final class FunctionDataTypeValidator {
 
@@ -40,12 +38,15 @@ final class FunctionDataTypeValidator {
         String parameterPath = fieldPath + ".parameters[" + i + "]";
         Preconditions.checkArgument(
             parameters[i] != null, "\"%s\" field is required and cannot be 
null", parameterPath);
-        validateDataType(parameters[i].getDataType(), parameterPath + 
".dataType");
+        WritableDataTypeValidator.validate(
+            parameters[i].getDataType(), parameterPath + ".dataType");
+        WritableDataTypeValidator.validateExpression(
+            parameters[i].defaultValue(), parameterPath + ".defaultValue");
       }
     }
 
     if (definition.getReturnType() != null) {
-      validateDataType(definition.getReturnType(), fieldPath + ".returnType");
+      WritableDataTypeValidator.validate(definition.getReturnType(), fieldPath 
+ ".returnType");
     }
 
     FunctionColumnDTO[] returnColumns = definition.getReturnColumns();
@@ -56,69 +57,9 @@ final class FunctionDataTypeValidator {
             returnColumns[i] != null,
             "\"%s\" field is required and cannot be null",
             returnColumnPath);
-        validateDataType(returnColumns[i].getDataType(), returnColumnPath + 
".dataType");
+        WritableDataTypeValidator.validate(
+            returnColumns[i].getDataType(), returnColumnPath + ".dataType");
       }
     }
   }
-
-  private static void validateDataType(Type dataType, String fieldPath) {
-    Preconditions.checkArgument(
-        dataType != null, "\"%s\" field is required and cannot be null", 
fieldPath);
-    Preconditions.checkArgument(
-        !(dataType instanceof Types.UnparsedType),
-        "\"%s\" must be a Gravitino-recognized data type or an explicit 
ExternalType; "
-            + "UnparsedType is not allowed for new function definitions",
-        fieldPath);
-
-    if (dataType instanceof Types.ExternalType) {
-      Types.ExternalType externalType = (Types.ExternalType) dataType;
-      Preconditions.checkArgument(
-          StringUtils.isNotBlank(externalType.catalogString()),
-          "\"%s.catalogString\" field is required and cannot be blank",
-          fieldPath);
-      return;
-    }
-
-    if (dataType instanceof Types.StructType) {
-      Types.StructType.Field[] fields = ((Types.StructType) dataType).fields();
-      Preconditions.checkArgument(
-          fields != null, "\"%s.fields\" field is required and cannot be 
null", fieldPath);
-      for (int i = 0; i < fields.length; i++) {
-        Preconditions.checkArgument(
-            fields[i] != null,
-            "\"%s.fields[%s]\" field is required and cannot be null",
-            fieldPath,
-            i);
-        validateDataType(fields[i].type(), fieldPath + ".fields[" + i + 
"].type");
-      }
-      return;
-    }
-
-    if (dataType instanceof Types.ListType) {
-      validateDataType(((Types.ListType) dataType).elementType(), fieldPath + 
".elementType");
-      return;
-    }
-
-    if (dataType instanceof Types.MapType) {
-      Types.MapType mapType = (Types.MapType) dataType;
-      validateDataType(mapType.keyType(), fieldPath + ".keyType");
-      validateDataType(mapType.valueType(), fieldPath + ".valueType");
-      return;
-    }
-
-    if (dataType instanceof Types.UnionType) {
-      Type[] types = ((Types.UnionType) dataType).types();
-      Preconditions.checkArgument(
-          types != null, "\"%s.types\" field is required and cannot be null", 
fieldPath);
-      for (int i = 0; i < types.length; i++) {
-        validateDataType(types[i], fieldPath + ".types[" + i + "]");
-      }
-      return;
-    }
-
-    Preconditions.checkArgument(
-        dataType instanceof Type.PrimitiveType || dataType instanceof 
Types.NullType,
-        "\"%s\" must be a Gravitino-recognized data type or an explicit 
ExternalType",
-        fieldPath);
-  }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/TableCreateRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/TableCreateRequest.java
index e9a33e6d77..d5d73a0bd1 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/TableCreateRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/TableCreateRequest.java
@@ -38,6 +38,7 @@ import org.apache.gravitino.dto.rel.expressions.FunctionArg;
 import org.apache.gravitino.dto.rel.indexes.IndexDTO;
 import org.apache.gravitino.dto.rel.partitioning.Partitioning;
 import org.apache.gravitino.rest.RESTRequest;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 
 /** Represents a request to create a table. */
 @Getter
@@ -123,6 +124,9 @@ public class TableCreateRequest implements RESTRequest {
     Preconditions.checkArgument(
         StringUtils.isNotBlank(name), "\"name\" field is required and cannot 
be empty");
 
+    // DTO conversion can normalize null literals and discard their declared 
types.
+    WritableDataTypeValidator.validateTable(columns, partitioning, 
distribution, sortOrders);
+
     if (sortOrders != null) {
       Arrays.stream(sortOrders).forEach(sortOrder -> 
sortOrder.validate(columns));
     }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/TableUpdateRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/TableUpdateRequest.java
index 300c15c3cb..9dcbc45586 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/TableUpdateRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/TableUpdateRequest.java
@@ -44,6 +44,7 @@ import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.rel.indexes.Indexes;
 import org.apache.gravitino.rel.types.Type;
 import org.apache.gravitino.rest.RESTRequest;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 
 /** Represents a request to update a table. */
 @JsonIgnoreProperties(ignoreUnknown = true)
@@ -98,6 +99,19 @@ public interface TableUpdateRequest extends RESTRequest {
    */
   TableChange tableChange();
 
+  /**
+   * Validates the request with a prefix for writable-type field paths.
+   *
+   * <p>The default implementation delegates to {@link #validate()} and 
preserves existing
+   * validation messages.
+   *
+   * @param fieldPrefix the field path prefix, including a trailing dot when 
nonempty
+   * @throws IllegalArgumentException if the request is invalid
+   */
+  default void validateWithPrefix(String fieldPrefix) throws 
IllegalArgumentException {
+    validate();
+  }
+
   /** Represents a request to rename a table. */
   @EqualsAndHashCode
   @ToString
@@ -397,13 +411,7 @@ public interface TableUpdateRequest extends RESTRequest {
      */
     @Override
     public void validate() throws IllegalArgumentException {
-      Preconditions.checkArgument(
-          fieldName != null
-              && fieldName.length > 0
-              && Arrays.stream(fieldName).allMatch(StringUtils::isNotBlank),
-          "\"fieldName\" field is required and cannot be empty");
-      Preconditions.checkArgument(
-          dataType != null, "\"type\" field is required and cannot be empty");
+      validateWithPrefix("");
     }
 
     /**
@@ -414,6 +422,21 @@ public interface TableUpdateRequest extends RESTRequest {
       return TableChange.addColumn(
           fieldName, dataType, comment, position, nullable, autoIncrement, 
defaultValue);
     }
+
+    /** {@inheritDoc} */
+    @Override
+    public void validateWithPrefix(String fieldPrefix) {
+      Preconditions.checkArgument(
+          fieldName != null
+              && fieldName.length > 0
+              && Arrays.stream(fieldName).allMatch(StringUtils::isNotBlank),
+          "\"%sfieldName\" field is required and cannot be empty",
+          fieldPrefix);
+      Preconditions.checkArgument(
+          dataType != null, "\"%stype\" field is required and cannot be 
empty", fieldPrefix);
+      WritableDataTypeValidator.validate(dataType, fieldPrefix + "type");
+      WritableDataTypeValidator.validateExpression(defaultValue, fieldPrefix + 
"defaultValue");
+    }
   }
 
   /** Represents a request to rename a column of a table. */
@@ -509,14 +532,7 @@ public interface TableUpdateRequest extends RESTRequest {
      */
     @Override
     public void validate() throws IllegalArgumentException {
-      Preconditions.checkArgument(
-          fieldName != null
-              && fieldName.length > 0
-              && Arrays.stream(fieldName).allMatch(StringUtils::isNotBlank),
-          "\"fieldName\" field is required and cannot be empty");
-      Preconditions.checkArgument(
-          (newDefaultValue != null && newDefaultValue != 
DEFAULT_VALUE_NOT_SET),
-          "\"newDefaultValue\" field is required and cannot be empty");
+      validateWithPrefix("");
     }
 
     /**
@@ -526,6 +542,23 @@ public interface TableUpdateRequest extends RESTRequest {
     public TableChange tableChange() {
       return TableChange.updateColumnDefaultValue(fieldName, newDefaultValue);
     }
+
+    /** {@inheritDoc} */
+    @Override
+    public void validateWithPrefix(String fieldPrefix) {
+      Preconditions.checkArgument(
+          fieldName != null
+              && fieldName.length > 0
+              && Arrays.stream(fieldName).allMatch(StringUtils::isNotBlank),
+          "\"%sfieldName\" field is required and cannot be empty",
+          fieldPrefix);
+      Preconditions.checkArgument(
+          (newDefaultValue != null && newDefaultValue != 
DEFAULT_VALUE_NOT_SET),
+          "\"%snewDefaultValue\" field is required and cannot be empty",
+          fieldPrefix);
+      WritableDataTypeValidator.validateExpression(
+          newDefaultValue, fieldPrefix + "newDefaultValue");
+    }
   }
 
   /** Represents a request to update the type of a column of a table. */
@@ -566,13 +599,7 @@ public interface TableUpdateRequest extends RESTRequest {
      */
     @Override
     public void validate() throws IllegalArgumentException {
-      Preconditions.checkArgument(
-          fieldName != null
-              && fieldName.length > 0
-              && Arrays.stream(fieldName).allMatch(StringUtils::isNotBlank),
-          "\"fieldName\" field is required and cannot be empty");
-      Preconditions.checkArgument(
-          newType != null, "\"newType\" field is required and cannot be 
empty");
+      validateWithPrefix("");
     }
 
     /**
@@ -582,6 +609,20 @@ public interface TableUpdateRequest extends RESTRequest {
     public TableChange tableChange() {
       return TableChange.updateColumnType(fieldName, newType);
     }
+
+    /** {@inheritDoc} */
+    @Override
+    public void validateWithPrefix(String fieldPrefix) {
+      Preconditions.checkArgument(
+          fieldName != null
+              && fieldName.length > 0
+              && Arrays.stream(fieldName).allMatch(StringUtils::isNotBlank),
+          "\"%sfieldName\" field is required and cannot be empty",
+          fieldPrefix);
+      Preconditions.checkArgument(
+          newType != null, "\"%snewType\" field is required and cannot be 
empty", fieldPrefix);
+      WritableDataTypeValidator.validate(newType, fieldPrefix + "newType");
+    }
   }
 
   /** Represents a request to update the comment of a column of a table. */
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/TableUpdatesRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/TableUpdatesRequest.java
index e347f9b681..b95df9ec93 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/TableUpdatesRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/TableUpdatesRequest.java
@@ -58,6 +58,8 @@ public class TableUpdatesRequest implements RESTRequest {
   public void validate() throws IllegalArgumentException {
     Preconditions.checkArgument(updates != null, "updates must not be null");
     Preconditions.checkArgument(!updates.isEmpty(), "updates must not be 
empty");
-    updates.forEach(RESTRequest::validate);
+    for (int i = 0; i < updates.size(); i++) {
+      updates.get(i).validateWithPrefix("updates[" + i + "].");
+    }
   }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/ViewCreateRequest.java 
b/common/src/main/java/org/apache/gravitino/dto/requests/ViewCreateRequest.java
index 8c0d02305a..c00481dfac 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/ViewCreateRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/ViewCreateRequest.java
@@ -36,6 +36,7 @@ import org.apache.gravitino.dto.rel.ColumnDTO;
 import org.apache.gravitino.dto.rel.RepresentationDTO;
 import org.apache.gravitino.dto.rel.SQLRepresentationDTO;
 import org.apache.gravitino.rest.RESTRequest;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 
 /** Represents a request to create a view. */
 @Getter
@@ -126,6 +127,7 @@ public class ViewCreateRequest implements RESTRequest {
                 Preconditions.checkArgument(column != null, "column must not 
be null");
                 column.validate();
               });
+      WritableDataTypeValidator.validateColumns(columns, "columns");
     }
 
     validateNoDuplicateDialects(representations);
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/ViewUpdateRequest.java 
b/common/src/main/java/org/apache/gravitino/dto/requests/ViewUpdateRequest.java
index c203086bd9..79b27d020d 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/ViewUpdateRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/ViewUpdateRequest.java
@@ -34,6 +34,7 @@ import org.apache.gravitino.dto.rel.RepresentationDTO;
 import org.apache.gravitino.dto.util.DTOConverters;
 import org.apache.gravitino.rel.ViewChange;
 import org.apache.gravitino.rest.RESTRequest;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 
 /** Represents a request to update a view. */
 @JsonIgnoreProperties(ignoreUnknown = true)
@@ -55,6 +56,19 @@ public interface ViewUpdateRequest extends RESTRequest {
    */
   ViewChange viewChange();
 
+  /**
+   * Validates the request with a prefix for writable-type field paths.
+   *
+   * <p>The default implementation delegates to {@link #validate()} and 
preserves existing
+   * validation messages.
+   *
+   * @param fieldPrefix the field path prefix, including a trailing dot when 
nonempty
+   * @throws IllegalArgumentException if the request is invalid
+   */
+  default void validateWithPrefix(String fieldPrefix) throws 
IllegalArgumentException {
+    validate();
+  }
+
   /** Represents a request to rename a view. */
   @EqualsAndHashCode
   @ToString
@@ -222,6 +236,22 @@ public interface ViewUpdateRequest extends RESTRequest {
 
     @Override
     public void validate() throws IllegalArgumentException {
+      validateWithPrefix("");
+    }
+
+    @Override
+    public ViewChange viewChange() {
+      return ViewChange.replaceView(
+          columns == null ? new ColumnDTO[0] : columns,
+          DTOConverters.fromDTOs(representations),
+          defaultCatalog,
+          defaultSchema,
+          comment);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void validateWithPrefix(String fieldPrefix) {
       Preconditions.checkArgument(
           representations != null && representations.length > 0,
           "\"representations\" field is required and cannot be empty");
@@ -238,19 +268,10 @@ public interface ViewUpdateRequest extends RESTRequest {
                   Preconditions.checkArgument(column != null, "column must not 
be null");
                   column.validate();
                 });
+        WritableDataTypeValidator.validateColumns(columns, fieldPrefix + 
"columns");
       }
 
       ViewCreateRequest.validateNoDuplicateDialects(representations);
     }
-
-    @Override
-    public ViewChange viewChange() {
-      return ViewChange.replaceView(
-          columns == null ? new ColumnDTO[0] : columns,
-          DTOConverters.fromDTOs(representations),
-          defaultCatalog,
-          defaultSchema,
-          comment);
-    }
   }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/ViewUpdatesRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/ViewUpdatesRequest.java
index 8cf29b7788..d0d24f8b0c 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/ViewUpdatesRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/ViewUpdatesRequest.java
@@ -53,6 +53,8 @@ public class ViewUpdatesRequest implements RESTRequest {
   public void validate() throws IllegalArgumentException {
     Preconditions.checkArgument(updates != null, "updates must not be null");
     Preconditions.checkArgument(!updates.isEmpty(), "updates must not be 
empty");
-    updates.forEach(RESTRequest::validate);
+    for (int i = 0; i < updates.size(); i++) {
+      updates.get(i).validateWithPrefix("updates[" + i + "].");
+    }
   }
 }
diff --git 
a/common/src/main/java/org/apache/gravitino/utils/WritableDataTypeValidator.java
 
b/common/src/main/java/org/apache/gravitino/utils/WritableDataTypeValidator.java
new file mode 100644
index 0000000000..0c23b19c27
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/utils/WritableDataTypeValidator.java
@@ -0,0 +1,314 @@
+/*
+ * 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.utils;
+
+import com.google.common.base.Preconditions;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.function.FunctionColumn;
+import org.apache.gravitino.function.FunctionDefinition;
+import org.apache.gravitino.function.FunctionParam;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.expressions.Expression;
+import org.apache.gravitino.rel.expressions.FunctionExpression;
+import org.apache.gravitino.rel.expressions.distributions.Distribution;
+import org.apache.gravitino.rel.expressions.literals.Literal;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.partitions.IdentityPartition;
+import org.apache.gravitino.rel.partitions.ListPartition;
+import org.apache.gravitino.rel.partitions.Partition;
+import org.apache.gravitino.rel.partitions.RangePartition;
+import org.apache.gravitino.rel.types.Type;
+import org.apache.gravitino.rel.types.Types;
+
+/**
+ * Validates data types in newly written metadata. Read and import paths, and 
selectors identifying
+ * existing metadata, must retain support for {@link Types.UnparsedType}.
+ *
+ * <p>This checks the type representation, not whether a connector supports 
the type or whether a
+ * literal value matches its declared type.
+ */
+public final class WritableDataTypeValidator {
+
+  private WritableDataTypeValidator() {}
+
+  /**
+   * Validates a type, including all nested types.
+   *
+   * @param dataType The type to validate.
+   * @param fieldPath The request field path used in validation errors.
+   * @throws IllegalArgumentException If the type is unresolved or an external 
type is blank.
+   */
+  public static void validate(Type dataType, String fieldPath) {
+    Preconditions.checkArgument(
+        dataType != null, "\"%s\" field is required and cannot be null", 
fieldPath);
+    Preconditions.checkArgument(
+        !(dataType instanceof Types.UnparsedType),
+        "\"%s\" must be a Gravitino-recognized data type or an explicit 
ExternalType; "
+            + "UnparsedType is not allowed for new metadata",
+        fieldPath);
+
+    if (dataType instanceof Types.ExternalType) {
+      Types.ExternalType externalType = (Types.ExternalType) dataType;
+      Preconditions.checkArgument(
+          StringUtils.isNotBlank(externalType.catalogString()),
+          "\"%s.catalogString\" field is required and cannot be blank",
+          fieldPath);
+      return;
+    }
+
+    if (dataType instanceof Types.StructType) {
+      Types.StructType.Field[] fields = ((Types.StructType) dataType).fields();
+      Preconditions.checkArgument(
+          fields != null, "\"%s.fields\" field is required and cannot be 
null", fieldPath);
+      for (int i = 0; i < fields.length; i++) {
+        Preconditions.checkArgument(
+            fields[i] != null,
+            "\"%s.fields[%s]\" field is required and cannot be null",
+            fieldPath,
+            i);
+        validate(fields[i].type(), fieldPath + ".fields[" + i + "].type");
+      }
+      return;
+    }
+
+    if (dataType instanceof Types.ListType) {
+      validate(((Types.ListType) dataType).elementType(), fieldPath + 
".elementType");
+      return;
+    }
+
+    if (dataType instanceof Types.MapType) {
+      Types.MapType mapType = (Types.MapType) dataType;
+      validate(mapType.keyType(), fieldPath + ".keyType");
+      validate(mapType.valueType(), fieldPath + ".valueType");
+      return;
+    }
+
+    if (dataType instanceof Types.UnionType) {
+      Type[] types = ((Types.UnionType) dataType).types();
+      Preconditions.checkArgument(
+          types != null, "\"%s.types\" field is required and cannot be null", 
fieldPath);
+      for (int i = 0; i < types.length; i++) {
+        validate(types[i], fieldPath + ".types[" + i + "]");
+      }
+      return;
+    }
+
+    Preconditions.checkArgument(
+        dataType instanceof Type.PrimitiveType || dataType instanceof 
Types.NullType,
+        "\"%s\" must be a Gravitino-recognized data type or an explicit 
ExternalType",
+        fieldPath);
+  }
+
+  /**
+   * Validates literal types recursively within an expression and any 
partition assignments.
+   *
+   * @param expression The expression to validate, or null when absent.
+   * @param fieldPath The request field path used in validation errors.
+   * @throws IllegalArgumentException If a literal contains a non-writable 
type, a traversal array
+   *     is null, or a partition assignment is null or unsupported.
+   */
+  public static void validateExpression(@Nullable Expression expression, 
String fieldPath) {
+    if (expression == null) {
+      return;
+    }
+    if (expression instanceof Literal) {
+      validate(((Literal<?>) expression).dataType(), fieldPath + ".dataType");
+    }
+    if (expression instanceof Transform) {
+      Partition[] assignments = ((Transform) expression).assignments();
+      Preconditions.checkArgument(
+          assignments != null,
+          "\"%s.assignments\" field is required and cannot be null",
+          fieldPath);
+      for (int i = 0; i < assignments.length; i++) {
+        validatePartition(assignments[i], fieldPath + ".assignments[" + i + 
"]");
+      }
+    }
+    Expression[] children = expression.children();
+    String childField =
+        expression instanceof FunctionExpression || expression instanceof 
Transform
+            ? ".funcArgs"
+            : ".children";
+    Preconditions.checkArgument(
+        children != null, "\"%s\" field is required and cannot be null", 
fieldPath + childField);
+    for (int i = 0; i < children.length; i++) {
+      validateExpression(children[i], fieldPath + childField + "[" + i + "]");
+    }
+  }
+
+  /**
+   * Validates the literal types in a newly added partition.
+   *
+   * @param partition The partition to validate.
+   * @param fieldPath The request field path used in validation errors.
+   * @throws IllegalArgumentException If a literal contains a non-writable 
type, a traversal array
+   *     is null, or the partition is null or unsupported.
+   */
+  public static void validatePartition(Partition partition, String fieldPath) {
+    Preconditions.checkArgument(
+        partition != null, "\"%s\" field is required and cannot be null", 
fieldPath);
+    if (partition instanceof IdentityPartition) {
+      Literal<?>[] values = ((IdentityPartition) partition).values();
+      Preconditions.checkArgument(
+          values != null, "\"%s.values\" field is required and cannot be 
null", fieldPath);
+      for (int i = 0; i < values.length; i++) {
+        validateExpression(values[i], fieldPath + ".values[" + i + "]");
+      }
+    } else if (partition instanceof ListPartition) {
+      Literal<?>[][] lists = ((ListPartition) partition).lists();
+      Preconditions.checkArgument(
+          lists != null, "\"%s.lists\" field is required and cannot be null", 
fieldPath);
+      for (int i = 0; i < lists.length; i++) {
+        Preconditions.checkArgument(
+            lists[i] != null,
+            "\"%s.lists[%s]\" field is required and cannot be null",
+            fieldPath,
+            i);
+        for (int j = 0; j < lists[i].length; j++) {
+          validateExpression(lists[i][j], fieldPath + ".lists[" + i + "][" + j 
+ "]");
+        }
+      }
+    } else if (partition instanceof RangePartition) {
+      RangePartition range = (RangePartition) partition;
+      validateExpression(range.lower(), fieldPath + ".lower");
+      validateExpression(range.upper(), fieldPath + ".upper");
+    } else {
+      throw new IllegalArgumentException(
+          String.format(
+              "\"%s\" must be an IdentityPartition, ListPartition or 
RangePartition", fieldPath));
+    }
+  }
+
+  /**
+   * Validates the types and defaults of newly supplied table or view columns.
+   *
+   * @param columns The new columns.
+   * @param fieldPath The request field path of the columns array.
+   * @throws IllegalArgumentException If a new type or literal type is not 
writable, or the columns
+   *     array or a column is null.
+   */
+  public static void validateColumns(Column[] columns, String fieldPath) {
+    Preconditions.checkArgument(
+        columns != null, "\"%s\" field is required and cannot be null", 
fieldPath);
+    for (int i = 0; i < columns.length; i++) {
+      String columnPath = fieldPath + "[" + i + "]";
+      Preconditions.checkArgument(
+          columns[i] != null, "\"%s\" field is required and cannot be null", 
columnPath);
+      validate(columns[i].dataType(), columnPath + ".type");
+      validateExpression(columns[i].defaultValue(), columnPath + 
".defaultValue");
+    }
+  }
+
+  /**
+   * Validates types and expressions in a table creation request.
+   *
+   * @param columns The new columns.
+   * @param partitioning The partitioning, or null when absent.
+   * @param distribution The distribution, or null when absent.
+   * @param sortOrders The sort orders, or null when absent.
+   * @throws IllegalArgumentException If a new type is not writable, or a 
required array, column or
+   *     sort order is null.
+   */
+  public static void validateTable(
+      Column[] columns,
+      @Nullable Transform[] partitioning,
+      @Nullable Distribution distribution,
+      @Nullable SortOrder[] sortOrders) {
+    validateColumns(columns, "columns");
+    if (partitioning != null) {
+      for (int i = 0; i < partitioning.length; i++) {
+        validateExpression(partitioning[i], "partitioning[" + i + "]");
+      }
+    }
+    if (distribution != null) {
+      Expression[] expressions = distribution.expressions();
+      Preconditions.checkArgument(
+          expressions != null, "\"distribution.funcArgs\" field is required 
and cannot be null");
+      for (int i = 0; i < expressions.length; i++) {
+        validateExpression(expressions[i], "distribution.funcArgs[" + i + "]");
+      }
+    }
+    if (sortOrders != null) {
+      for (int i = 0; i < sortOrders.length; i++) {
+        Preconditions.checkArgument(
+            sortOrders[i] != null, "\"sortOrders[%s]\" field is required and 
cannot be null", i);
+        validateExpression(sortOrders[i].expression(), "sortOrders[" + i + 
"].sortTerm");
+      }
+    }
+  }
+
+  /**
+   * Validates only the types or defaults introduced by a table change.
+   *
+   * @param change The table change.
+   * @param fieldPath The request field path used in validation errors.
+   * @throws IllegalArgumentException If a new type is not writable.
+   */
+  public static void validateTableChange(TableChange change, String fieldPath) 
{
+    if (change instanceof TableChange.AddColumn) {
+      TableChange.AddColumn add = (TableChange.AddColumn) change;
+      validate(add.getDataType(), fieldPath + ".type");
+      validateExpression(add.getDefaultValue(), fieldPath + ".defaultValue");
+    } else if (change instanceof TableChange.UpdateColumnType) {
+      validate(((TableChange.UpdateColumnType) change).getNewDataType(), 
fieldPath + ".newType");
+    } else if (change instanceof TableChange.UpdateColumnDefaultValue) {
+      validateExpression(
+          ((TableChange.UpdateColumnDefaultValue) change).getNewDefaultValue(),
+          fieldPath + ".newDefaultValue");
+    }
+  }
+
+  /**
+   * Validates a newly registered function definition, including parameter 
defaults.
+   *
+   * @param definition The new definition.
+   * @param fieldPath The request field path used in validation errors.
+   * @throws IllegalArgumentException If a new type is not writable, or the 
definition, a required
+   *     array or an array element is null.
+   */
+  public static void validateDefinition(FunctionDefinition definition, String 
fieldPath) {
+    Preconditions.checkArgument(
+        definition != null, "\"%s\" field is required and cannot be null", 
fieldPath);
+    FunctionParam[] parameters = definition.parameters();
+    Preconditions.checkArgument(
+        parameters != null, "\"%s.parameters\" field is required and cannot be 
null", fieldPath);
+    for (int i = 0; i < parameters.length; i++) {
+      String parameterPath = fieldPath + ".parameters[" + i + "]";
+      Preconditions.checkArgument(
+          parameters[i] != null, "\"%s\" field is required and cannot be 
null", parameterPath);
+      validate(parameters[i].dataType(), parameterPath + ".dataType");
+      validateExpression(parameters[i].defaultValue(), parameterPath + 
".defaultValue");
+    }
+    if (definition.returnType() != null) {
+      validate(definition.returnType(), fieldPath + ".returnType");
+    }
+    FunctionColumn[] columns = definition.returnColumns();
+    Preconditions.checkArgument(
+        columns != null, "\"%s.returnColumns\" field is required and cannot be 
null", fieldPath);
+    for (int i = 0; i < columns.length; i++) {
+      String columnPath = fieldPath + ".returnColumns[" + i + "]";
+      Preconditions.checkArgument(
+          columns[i] != null, "\"%s\" field is required and cannot be null", 
columnPath);
+      validate(columns[i].dataType(), columnPath + ".dataType");
+    }
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/requests/TestWritableDataTypeRequests.java
 
b/common/src/test/java/org/apache/gravitino/dto/requests/TestWritableDataTypeRequests.java
new file mode 100644
index 0000000000..392698fa10
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/requests/TestWritableDataTypeRequests.java
@@ -0,0 +1,370 @@
+/*
+ * 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.requests;
+
+import java.util.stream.Stream;
+import org.apache.gravitino.dto.rel.ColumnDTO;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.rest.RESTRequest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class TestWritableDataTypeRequests {
+
+  @ParameterizedTest
+  @MethodSource("invalidColumnTypes")
+  public void testRejectNewColumnTypes(String type, String path) throws 
Exception {
+    TableCreateRequest request =
+        JsonUtils.objectMapper()
+            .readValue(
+                "{\"name\":\"t\",\"columns\":[{\"name\":\"c\",\"type\":" + 
type + "}]}",
+                TableCreateRequest.class);
+    assertRejected(request, "columns[0].type" + path);
+
+    TableUpdatesRequest add =
+        JsonUtils.objectMapper()
+            .readValue(
+                
"{\"updates\":[{\"@type\":\"addColumn\",\"fieldName\":[\"c\"],\"type\":"
+                    + type
+                    + "}]}",
+                TableUpdatesRequest.class);
+    assertRejected(add, "updates[0].type" + path);
+
+    TableUpdatesRequest update =
+        JsonUtils.objectMapper()
+            .readValue(
+                
"{\"updates\":[{\"@type\":\"updateColumnType\",\"fieldName\":[\"c\"],\"newType\":"
+                    + type
+                    + "}]}",
+                TableUpdatesRequest.class);
+    assertRejected(update, "updates[0].newType" + path);
+  }
+
+  @ParameterizedTest
+  @MethodSource("invalidTableUpdates")
+  public void testTableUpdateErrorPathsIncludeIndex(String update, String 
path) throws Exception {
+    TableUpdatesRequest request =
+        JsonUtils.objectMapper()
+            .readValue(
+                """
+                
{"updates":[{"@type":"updateComment","newComment":"updated"},%s]}
+                """
+                    .formatted(update),
+                TableUpdatesRequest.class);
+    assertRejected(request, "updates[1]." + path);
+    assertRejected(request.getUpdates().get(1), path);
+  }
+
+  @Test
+  public void testPreserveValidationForUpdatesWithoutTypes() throws Exception {
+    String json = "{\"updates\":[{\"@type\":\"rename\",\"newName\":\"\"}]}";
+    for (RESTRequest request :
+        new RESTRequest[] {
+          JsonUtils.objectMapper().readValue(json, TableUpdatesRequest.class),
+          JsonUtils.objectMapper().readValue(json, ViewUpdatesRequest.class)
+        }) {
+      IllegalArgumentException exception =
+          Assertions.assertThrows(IllegalArgumentException.class, 
request::validate);
+      Assertions.assertEquals(
+          "\"newName\" field is required and cannot be empty", 
exception.getMessage());
+    }
+  }
+
+  @Test
+  public void testAcceptExplicitExternalAndNativeTypes() throws Exception {
+    for (String type :
+        new String[] {
+          "\"integer\"",
+          "{\"type\":\"external\",\"catalogString\":\"jsonb\"}",
+          
"{\"type\":\"list\",\"elementType\":{\"type\":\"external\",\"catalogString\":\"jsonb\"}}"
+        }) {
+      TableCreateRequest create =
+          JsonUtils.objectMapper()
+              .readValue(
+                  "{\"name\":\"t\",\"columns\":[{\"name\":\"c\",\"type\":" + 
type + "}]}",
+                  TableCreateRequest.class);
+      Assertions.assertDoesNotThrow(create::validate);
+      TableUpdatesRequest update =
+          JsonUtils.objectMapper()
+              .readValue(
+                  
"{\"updates\":[{\"@type\":\"updateColumnType\",\"fieldName\":[\"c\"],\"newType\":"
+                      + type
+                      + "}]}",
+                  TableUpdatesRequest.class);
+      Assertions.assertDoesNotThrow(update::validate);
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("invalidColumnTypes")
+  public void testRejectNewViewColumnTypes(String type, String path) throws 
Exception {
+    ViewCreateRequest create =
+        JsonUtils.objectMapper()
+            .readValue(
+                """
+                {"name":"v","columns":[{"name":"c","type":%s}],
+                 
"representations":[{"type":"sql","dialect":"trino","sql":"SELECT 1"}]}
+                """
+                    .formatted(type),
+                ViewCreateRequest.class);
+    assertRejected(create, "columns[0].type" + path);
+
+    ViewUpdatesRequest replace =
+        JsonUtils.objectMapper()
+            .readValue(
+                """
+                {"updates":[{"@type":"setProperty","property":"k","value":"v"},
+                  {"@type":"replaceView","columns":[{"name":"c","type":%s}],
+                   
"representations":[{"type":"sql","dialect":"trino","sql":"SELECT 1"}]}]}
+                """
+                    .formatted(type),
+                ViewUpdatesRequest.class);
+    assertRejected(replace, "updates[1].columns[0].type" + path);
+    assertRejected(replace.getUpdates().get(1), "columns[0].type" + path);
+  }
+
+  @Test
+  public void testAcceptOptionalAndWritableViewColumns() throws Exception {
+    for (String columns :
+        new String[] {
+          "null",
+          "[]",
+          """
+          [{"name":"c","type":"timestamp","defaultValue":{
+            "type":"unparsed","unparsedExpression":"CURRENT_TIMESTAMP"}}]
+          """,
+          """
+          [{"name":"c","type":{"type":"external","catalogString":"jsonb"}}]
+          """,
+          """
+          [{"name":"c","type":{"type":"list","elementType":{
+            "type":"external","catalogString":"jsonb"}}}]
+          """
+        }) {
+      ViewCreateRequest create =
+          JsonUtils.objectMapper()
+              .readValue(
+                  """
+                  {"name":"v","columns":%s,
+                   
"representations":[{"type":"sql","dialect":"trino","sql":"SELECT 1"}]}
+                  """
+                      .formatted(columns),
+                  ViewCreateRequest.class);
+      Assertions.assertDoesNotThrow(create::validate);
+      ViewUpdatesRequest replace =
+          JsonUtils.objectMapper()
+              .readValue(
+                  """
+                  {"updates":[{"@type":"replaceView","columns":%s,
+                    
"representations":[{"type":"sql","dialect":"trino","sql":"SELECT 1"}]}]}
+                  """
+                      .formatted(columns),
+                  ViewUpdatesRequest.class);
+      Assertions.assertDoesNotThrow(replace::validate);
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("expressionRequests")
+  public void testValidateLiteralTypesBeforeConversion(
+      Class<? extends RESTRequest> requestClass, String json, String path) 
throws Exception {
+    // A null literal value must not erase an unresolved type before 
validation.
+    for (String value : new String[] {"null", "\"value\""}) {
+      String literal =
+          "{\"type\":\"literal\",\"dataType\":\"future_type\",\"value\":" + 
value + "}";
+      RESTRequest invalid =
+          JsonUtils.objectMapper().readValue(json.formatted(literal), 
requestClass);
+      assertRejected(invalid, path);
+
+      String blankExternal =
+          literal.replace("\"future_type\"", 
"{\"type\":\"external\",\"catalogString\":\" \"}");
+      assertRejected(
+          JsonUtils.objectMapper().readValue(json.formatted(blankExternal), 
requestClass),
+          path + ".catalogString");
+
+      String external =
+          literal.replace("\"future_type\"", 
"{\"type\":\"external\",\"catalogString\":\"jsonb\"}");
+      RESTRequest valid =
+          JsonUtils.objectMapper().readValue(json.formatted(external), 
requestClass);
+      Assertions.assertDoesNotThrow(valid::validate);
+    }
+  }
+
+  @Test
+  public void testPreserveNullOptionsAndSqlDefaults() throws Exception {
+    String json =
+        """
+        {"name":"events","columns":[
+          {"name":"id","type":"integer","defaultValue":{
+            "type":"literal","dataType":"integer","value":null}},
+          {"name":"created_at","type":"timestamp","defaultValue":{
+            "type":"unparsed","unparsedExpression":"CURRENT_TIMESTAMP"}}],
+         "distribution":%s}
+        """;
+    for (String distribution : new String[] {"null", 
"{\"number\":0,\"funcArgs\":null}"}) {
+      TableCreateRequest request =
+          JsonUtils.objectMapper()
+              .readValue(json.formatted(distribution), 
TableCreateRequest.class);
+      Assertions.assertDoesNotThrow(request::validate);
+    }
+  }
+
+  @Test
+  public void testReadingLegacyColumnRetainsUnparsedType() throws Exception {
+    ColumnDTO column =
+        JsonUtils.objectMapper()
+            .readValue(
+                
"{\"name\":\"legacy\",\"type\":\"future_type\",\"defaultValue\":{\"type\":\"literal\",\"dataType\":\"future_type\",\"value\":null}}",
+                ColumnDTO.class);
+    Assertions.assertInstanceOf(Types.UnparsedType.class, column.dataType());
+    Assertions.assertDoesNotThrow(column::validate);
+    ColumnDTO reloaded =
+        JsonUtils.objectMapper()
+            .readValue(JsonUtils.objectMapper().writeValueAsString(column), 
ColumnDTO.class);
+    Assertions.assertEquals(column, reloaded);
+  }
+
+  private static Stream<Arguments> invalidColumnTypes() {
+    return Stream.of(
+        Arguments.of("\"future_type\"", ""),
+        Arguments.of("{\"type\":\"unparsed\",\"unparsedType\":\"legacy\"}", 
""),
+        Arguments.of("{\"type\":\"external\",\"catalogString\":\" \"}", 
".catalogString"),
+        Arguments.of("{\"type\":\"list\",\"elementType\":\"future_type\"}", 
".elementType"),
+        Arguments.of(
+            
"{\"type\":\"map\",\"keyType\":\"future_type\",\"valueType\":\"integer\"}", 
".keyType"),
+        Arguments.of(
+            
"{\"type\":\"map\",\"keyType\":\"string\",\"valueType\":\"future_type\"}",
+            ".valueType"),
+        
Arguments.of("{\"type\":\"union\",\"types\":[\"integer\",\"future_type\"]}", 
".types[1]"),
+        Arguments.of(
+            
"{\"type\":\"struct\",\"fields\":[{\"name\":\"x\",\"type\":\"future_type\"}]}",
+            ".fields[0].type"));
+  }
+
+  private static Stream<Arguments> expressionRequests() {
+    return Stream.of(
+        Arguments.of(
+            TableCreateRequest.class,
+            
"{\"name\":\"t\",\"columns\":[{\"name\":\"c\",\"type\":\"string\",\"defaultValue\":%s}]}",
+            "columns[0].defaultValue.dataType"),
+        Arguments.of(
+            ViewCreateRequest.class,
+            """
+            
{"name":"v","columns":[{"name":"c","type":"string","defaultValue":%s}],
+             "representations":[{"type":"sql","dialect":"trino","sql":"SELECT 
1"}]}
+            """,
+            "columns[0].defaultValue.dataType"),
+        Arguments.of(
+            ViewUpdatesRequest.class,
+            """
+            {"updates":[{"@type":"replaceView","columns":[
+              {"name":"c","type":"string","defaultValue":%s}],
+              "representations":[{"type":"sql","dialect":"trino","sql":"SELECT 
1"}]}]}
+            """,
+            "updates[0].columns[0].defaultValue.dataType"),
+        Arguments.of(
+            TableUpdatesRequest.class,
+            
"{\"updates\":[{\"@type\":\"addColumn\",\"fieldName\":[\"c\"],\"type\":\"string\",\"defaultValue\":%s}]}",
+            "updates[0].defaultValue.dataType"),
+        Arguments.of(
+            TableUpdatesRequest.class,
+            
"{\"updates\":[{\"@type\":\"updateColumnDefaultValue\",\"fieldName\":[\"c\"],\"newDefaultValue\":{\"type\":\"function\",\"funcName\":\"f\",\"funcArgs\":[%s]}}]}",
+            "updates[0].newDefaultValue.funcArgs[0].dataType"),
+        Arguments.of(
+            FunctionRegisterRequest.class,
+            
"{\"name\":\"f\",\"functionType\":\"SCALAR\",\"deterministic\":true,\"definitions\":[{\"parameters\":[{\"name\":\"x\",\"dataType\":\"string\",\"defaultValue\":%s}],\"returnType\":\"string\"}]}",
+            "definitions[0].parameters[0].defaultValue.dataType"),
+        Arguments.of(
+            FunctionUpdatesRequest.class,
+            
"{\"updates\":[{\"@type\":\"addDefinition\",\"definition\":{\"parameters\":[{\"name\":\"x\",\"dataType\":\"string\",\"defaultValue\":%s}],\"returnType\":\"string\"}}]}",
+            "updates[0].definition.parameters[0].defaultValue.dataType"),
+        Arguments.of(
+            AddPartitionsRequest.class,
+            
"{\"partitions\":[{\"type\":\"identity\",\"name\":\"p\",\"fieldNames\":[[\"c\"]],\"values\":[%s]}]}",
+            "partitions[0].values[0].dataType"),
+        Arguments.of(
+            AddPartitionsRequest.class,
+            
"{\"partitions\":[{\"type\":\"list\",\"name\":\"p\",\"lists\":[[%s]]}]}",
+            "partitions[0].lists[0][0].dataType"),
+        Arguments.of(
+            AddPartitionsRequest.class,
+            
"{\"partitions\":[{\"type\":\"range\",\"name\":\"p\",\"lower\":%s,\"upper\":{\"type\":\"literal\",\"dataType\":\"integer\",\"value\":\"10\"}}]}",
+            "partitions[0].lower.dataType"),
+        Arguments.of(
+            AddPartitionsRequest.class,
+            
"{\"partitions\":[{\"type\":\"range\",\"name\":\"p\",\"upper\":%s,\"lower\":{\"type\":\"literal\",\"dataType\":\"integer\",\"value\":\"0\"}}]}",
+            "partitions[0].upper.dataType"),
+        Arguments.of(
+            TableCreateRequest.class,
+            
"{\"name\":\"t\",\"columns\":[{\"name\":\"c\",\"type\":\"string\"}],\"partitioning\":[{\"strategy\":\"list\",\"fieldNames\":[[\"c\"]],\"assignments\":[{\"type\":\"list\",\"name\":\"p\",\"lists\":[[%s]]}]}]}",
+            "partitioning[0].assignments[0].lists[0][0].dataType"),
+        Arguments.of(
+            TableCreateRequest.class,
+            
"{\"name\":\"t\",\"columns\":[{\"name\":\"c\",\"type\":\"string\"}],\"partitioning\":[{\"strategy\":\"function\",\"funcName\":\"f\",\"funcArgs\":[%s]}]}",
+            "partitioning[0].funcArgs[0].dataType"),
+        Arguments.of(
+            TableCreateRequest.class,
+            
"{\"name\":\"t\",\"columns\":[{\"name\":\"c\",\"type\":\"string\"}],\"distribution\":{\"strategy\":\"hash\",\"number\":2,\"funcArgs\":[%s]}}",
+            "distribution.funcArgs[0].dataType"),
+        Arguments.of(
+            TableCreateRequest.class,
+            
"{\"name\":\"t\",\"columns\":[{\"name\":\"c\",\"type\":\"string\"}],\"sortOrders\":[{\"sortTerm\":%s,\"direction\":\"asc\",\"nullOrdering\":\"nulls_first\"}]}",
+            "sortOrders[0].sortTerm.dataType"));
+  }
+
+  private static Stream<Arguments> invalidTableUpdates() {
+    return Stream.of(
+        Arguments.of(
+            """
+            {"@type":"addColumn","fieldName":["c"],
+             "type":{"type":"list","elementType":"future_type"}}
+            """,
+            "type.elementType"),
+        Arguments.of(
+            """
+            {"@type":"addColumn","fieldName":["c"],"type":"string",
+             "defaultValue":{"type":"function","funcName":"f","funcArgs":[
+               {"type":"literal","dataType":"future_type","value":null}]}}
+            """,
+            "defaultValue.funcArgs[0].dataType"),
+        Arguments.of(
+            """
+            {"@type":"updateColumnType","fieldName":["c"],
+             
"newType":{"type":"struct","fields":[{"name":"nested","type":"future_type"}]}}
+            """,
+            "newType.fields[0].type"),
+        Arguments.of(
+            """
+            {"@type":"updateColumnDefaultValue","fieldName":["c"],
+             "newDefaultValue":{"type":"function","funcName":"f","funcArgs":[
+               
{"type":"literal","dataType":{"type":"external","catalogString":" 
"},"value":null}]}}
+            """,
+            "newDefaultValue.funcArgs[0].dataType.catalogString"));
+  }
+
+  private static void assertRejected(RESTRequest request, String path) {
+    IllegalArgumentException exception =
+        Assertions.assertThrows(IllegalArgumentException.class, 
request::validate);
+    Assertions.assertTrue(exception.getMessage().contains(path), 
exception.getMessage());
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/utils/TestWritableDataTypeValidator.java
 
b/common/src/test/java/org/apache/gravitino/utils/TestWritableDataTypeValidator.java
new file mode 100644
index 0000000000..08da46be31
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/utils/TestWritableDataTypeValidator.java
@@ -0,0 +1,246 @@
+/*
+ * 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.utils;
+
+import java.util.Map;
+import java.util.stream.Stream;
+import org.apache.gravitino.function.FunctionColumn;
+import org.apache.gravitino.function.FunctionDefinition;
+import org.apache.gravitino.function.FunctionDefinitions;
+import org.apache.gravitino.function.FunctionImpl;
+import org.apache.gravitino.function.FunctionImpls;
+import org.apache.gravitino.function.FunctionParam;
+import org.apache.gravitino.function.FunctionParams;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.expressions.Expression;
+import org.apache.gravitino.rel.expressions.FunctionExpression;
+import org.apache.gravitino.rel.expressions.distributions.Distribution;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.literals.Literal;
+import org.apache.gravitino.rel.expressions.literals.Literals;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.sorts.SortOrders;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.apache.gravitino.rel.partitions.ListPartition;
+import org.apache.gravitino.rel.partitions.Partition;
+import org.apache.gravitino.rel.partitions.Partitions;
+import org.apache.gravitino.rel.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class TestWritableDataTypeValidator {
+
+  private static final FunctionImpl[] IMPLS = {
+    FunctionImpls.ofJava(FunctionImpl.RuntimeType.SPARK, "com.example.UDF")
+  };
+
+  @ParameterizedTest
+  @MethodSource("invalidExpressions")
+  public void testRejectNullExpressionArraysAndAssignments(Expression 
expression, String path) {
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> WritableDataTypeValidator.validateExpression(expression, 
"expression"));
+    Assertions.assertEquals(
+        "\"" + path + "\" field is required and cannot be null", 
exception.getMessage());
+  }
+
+  @ParameterizedTest
+  @MethodSource("invalidPartitions")
+  public void testRejectNullPartitionArrays(Partition partition, String path) {
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> WritableDataTypeValidator.validatePartition(partition, 
"partition"));
+    Assertions.assertEquals(
+        "\"" + path + "\" field is required and cannot be null", 
exception.getMessage());
+  }
+
+  @ParameterizedTest
+  @MethodSource("invalidFunctionDefinitions")
+  public void testRejectNullFunctionArraysAndElements(FunctionDefinition 
definition, String path) {
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> WritableDataTypeValidator.validateDefinition(definition, 
"definition"));
+    Assertions.assertEquals(
+        "\"" + path + "\" field is required and cannot be null", 
exception.getMessage());
+  }
+
+  @ParameterizedTest
+  @MethodSource("invalidTableInputs")
+  public void testRejectNullTableInputs(
+      Column[] columns, Distribution distribution, SortOrder[] sortOrders, 
String path) {
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> WritableDataTypeValidator.validateTable(columns, null, 
distribution, sortOrders));
+    Assertions.assertEquals(
+        "\"" + path + "\" field is required and cannot be null", 
exception.getMessage());
+  }
+
+  @Test
+  public void testRejectUnsupportedPartition() {
+    Partition partition =
+        new Partition() {
+          @Override
+          public String name() {
+            return "p";
+          }
+
+          @Override
+          public Map<String, String> properties() {
+            return Map.of();
+          }
+        };
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> WritableDataTypeValidator.validatePartition(partition, 
"partition"));
+    Assertions.assertEquals(
+        "\"partition\" must be an IdentityPartition, ListPartition or 
RangePartition",
+        exception.getMessage());
+  }
+
+  @Test
+  public void testPreserveAbsentExpressionsAndEmptyArrays() {
+    Assertions.assertDoesNotThrow(
+        () -> WritableDataTypeValidator.validateExpression(null, 
"defaultValue"));
+    Assertions.assertDoesNotThrow(
+        () -> WritableDataTypeValidator.validateExpression(Literals.NULL, 
"defaultValue"));
+    Assertions.assertDoesNotThrow(
+        () ->
+            
WritableDataTypeValidator.validateExpression(FunctionExpression.of("f"), 
"expression"));
+    Assertions.assertDoesNotThrow(
+        () ->
+            WritableDataTypeValidator.validateExpression(
+                Transforms.list(new String[] {"c"}), "partitioning[0]"));
+    Assertions.assertDoesNotThrow(
+        () ->
+            WritableDataTypeValidator.validatePartition(
+                Partitions.list("p", new Literal<?>[0][], null), "partition"));
+  }
+
+  @Test
+  public void testPreserveEmptyFunctionArraysAndOptionalReturnType() {
+    FunctionDefinition scalar = FunctionDefinitions.of(null, 
Types.StringType.get(), IMPLS);
+    Assertions.assertDoesNotThrow(
+        () -> WritableDataTypeValidator.validateDefinition(scalar, 
"definition"));
+    FunctionDefinition table =
+        FunctionDefinitions.of(
+            null,
+            new FunctionColumn[] {FunctionColumn.of("c", 
Types.StringType.get(), null)},
+            IMPLS);
+    Assertions.assertNull(table.returnType());
+    Assertions.assertDoesNotThrow(
+        () -> WritableDataTypeValidator.validateDefinition(table, 
"definition"));
+  }
+
+  @Test
+  public void testPreserveAbsentTableOptionsAndEmptyArrays() {
+    Assertions.assertDoesNotThrow(
+        () -> WritableDataTypeValidator.validateTable(new Column[0], null, 
null, null));
+    Assertions.assertDoesNotThrow(
+        () ->
+            WritableDataTypeValidator.validateTable(
+                new Column[0], null, Distributions.NONE, SortOrders.NONE));
+  }
+
+  private static Stream<Arguments> invalidExpressions() {
+    return Stream.of(
+        Arguments.of(FunctionExpression.of("f", (Expression[]) null), 
"expression.funcArgs"),
+        Arguments.of(Transforms.apply("f", null), "expression.funcArgs"),
+        Arguments.of((Expression) () -> null, "expression.children"),
+        Arguments.of(Transforms.list(new String[][] {{"c"}}, null), 
"expression.assignments"),
+        Arguments.of(Transforms.range(new String[] {"c"}, null), 
"expression.assignments"),
+        Arguments.of(
+            Transforms.list(new String[][] {{"c"}}, new ListPartition[] 
{null}),
+            "expression.assignments[0]"));
+  }
+
+  private static Stream<Arguments> invalidPartitions() {
+    return Stream.of(
+        Arguments.of(null, "partition"),
+        Arguments.of(
+            Partitions.identity("p", new String[][] {{"c"}}, null, null), 
"partition.values"),
+        Arguments.of(Partitions.list("p", null, null), "partition.lists"),
+        Arguments.of(
+            Partitions.list("p", new Literal<?>[][] 
{{Literals.integerLiteral(1)}, null}, null),
+            "partition.lists[1]"));
+  }
+
+  private static Stream<Arguments> invalidFunctionDefinitions() {
+    return Stream.of(
+        Arguments.of(
+            definitionWithArrays(null, FunctionDefinition.EMPTY_COLUMNS), 
"definition.parameters"),
+        Arguments.of(
+            FunctionDefinitions.of(
+                new FunctionParam[] {FunctionParams.of("x", 
Types.StringType.get()), null},
+                Types.StringType.get(),
+                IMPLS),
+            "definition.parameters[1]"),
+        Arguments.of(definitionWithArrays(new FunctionParam[0], null), 
"definition.returnColumns"),
+        Arguments.of(
+            FunctionDefinitions.of(
+                new FunctionParam[0],
+                new FunctionColumn[] {FunctionColumn.of("c", 
Types.StringType.get(), null), null},
+                IMPLS),
+            "definition.returnColumns[1]"));
+  }
+
+  private static Stream<Arguments> invalidTableInputs() {
+    Column column = Column.of("c", Types.StringType.get());
+    return Stream.of(
+        Arguments.of(null, null, null, "columns"),
+        Arguments.of(new Column[] {column, null}, null, null, "columns[1]"),
+        Arguments.of(
+            new Column[] {column},
+            Distributions.hash(2, (Expression[]) null),
+            null,
+            "distribution.funcArgs"),
+        Arguments.of(
+            new Column[] {column},
+            null,
+            new SortOrder[] {SortOrders.ascending(Literals.integerLiteral(1)), 
null},
+            "sortOrders[1]"));
+  }
+
+  private static FunctionDefinition definitionWithArrays(
+      FunctionParam[] parameters, FunctionColumn[] columns) {
+    return new FunctionDefinition() {
+      @Override
+      public FunctionParam[] parameters() {
+        return parameters;
+      }
+
+      @Override
+      public FunctionColumn[] returnColumns() {
+        return columns;
+      }
+
+      @Override
+      public FunctionImpl[] impls() {
+        return IMPLS;
+      }
+    };
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/FunctionOperationDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/catalog/FunctionOperationDispatcher.java
index 923b5fb452..82612f6f3a 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/FunctionOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/FunctionOperationDispatcher.java
@@ -36,6 +36,7 @@ import org.apache.gravitino.lock.LockType;
 import org.apache.gravitino.lock.TreeLockUtils;
 import org.apache.gravitino.secret.SecretManager;
 import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 
 /**
  * {@code FunctionOperationDispatcher} is responsible for dispatching 
function-related operations.
@@ -141,6 +142,9 @@ public class FunctionOperationDispatcher extends 
OperationDispatcher implements
     Preconditions.checkArgument(functionType != null, "Function type is 
required");
     Preconditions.checkArgument(
         definitions != null && definitions.length > 0, "At least one 
definition is required");
+    for (int i = 0; i < definitions.length; i++) {
+      WritableDataTypeValidator.validateDefinition(definitions[i], 
"definitions[" + i + "]");
+    }
 
     validateNotIcebergReservedSchema(ident);
 
@@ -170,6 +174,13 @@ public class FunctionOperationDispatcher extends 
OperationDispatcher implements
       throws NoSuchFunctionException, IllegalArgumentException {
     Preconditions.checkArgument(
         changes != null && changes.length > 0, "At least one change is 
required");
+    for (int i = 0; i < changes.length; i++) {
+      if (changes[i] instanceof FunctionChange.AddDefinition) {
+        WritableDataTypeValidator.validateDefinition(
+            ((FunctionChange.AddDefinition) changes[i]).definition(),
+            "updates[" + i + "].definition");
+      }
+    }
     validateNotIcebergReservedSchema(ident);
 
     NameIdentifier schemaIdent = NameIdentifier.of(ident.namespace().levels());
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/PartitionOperationDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/catalog/PartitionOperationDispatcher.java
index 9d5757884c..dc1f657266 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/PartitionOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/PartitionOperationDispatcher.java
@@ -27,6 +27,7 @@ import org.apache.gravitino.rel.SupportsPartitions;
 import org.apache.gravitino.rel.partitions.Partition;
 import org.apache.gravitino.secret.SecretManager;
 import org.apache.gravitino.storage.IdGenerator;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 
 public class PartitionOperationDispatcher extends OperationDispatcher
     implements PartitionDispatcher {
@@ -68,6 +69,7 @@ public class PartitionOperationDispatcher extends 
OperationDispatcher
   @Override
   public Partition addPartition(NameIdentifier tableIdent, Partition partition)
       throws PartitionAlreadyExistsException {
+    WritableDataTypeValidator.validatePartition(partition, "partition");
     return doWithTable(
         tableIdent, p -> p.addPartition(partition), 
PartitionAlreadyExistsException.class);
   }
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java 
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
index 9eb60ee9f0..d329725f07 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
@@ -83,6 +83,7 @@ import org.apache.gravitino.secret.SecretManager;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.utils.NamespaceUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -226,6 +227,7 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
       throws NoSuchSchemaException, TableAlreadyExistsException {
 
     TableEntity.NAME.validate(ident.name(), TABLE);
+    WritableDataTypeValidator.validateTable(columns, partitions, distribution, 
sortOrders);
     validateColumns(columns);
 
     // Load the schema to make sure the schema exists.
@@ -288,6 +290,9 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
     NameIdentifier nameIdentifierForLock = ident;
     String schemaName = ident.namespace().level(2);
     
Arrays.stream(changes).forEach(TableOperationDispatcher::validateColumnChange);
+    for (int i = 0; i < changes.length; i++) {
+      WritableDataTypeValidator.validateTableChange(changes[i], "updates[" + i 
+ "]");
+    }
     for (TableChange change : changes) {
       if (change instanceof TableChange.RenameTable) {
         TableChange.RenameTable rename = (TableChange.RenameTable) change;
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java 
b/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
index 622e9b6a6d..141e370e5c 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
@@ -57,6 +57,7 @@ import org.apache.gravitino.rel.ViewChange;
 import org.apache.gravitino.secret.SecretManager;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.gravitino.utils.WritableDataTypeValidator;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -183,6 +184,9 @@ public class ViewOperationDispatcher extends 
OperationDispatcher implements View
     Preconditions.checkArgument(
         representations != null && representations.length >= 1,
         "representations must not be null or empty");
+    if (columns != null) {
+      WritableDataTypeValidator.validateColumns(columns, "columns");
+    }
 
     // Load the schema to make sure the schema exists.
     SchemaDispatcher schemaDispatcher = getSchemaDispatcher();
@@ -220,6 +224,12 @@ public class ViewOperationDispatcher extends 
OperationDispatcher implements View
   @Override
   public View alterView(NameIdentifier ident, ViewChange... changes)
       throws NoSuchViewException, IllegalArgumentException {
+    for (int i = 0; i < changes.length; i++) {
+      if (changes[i] instanceof ViewChange.ReplaceView) {
+        WritableDataTypeValidator.validateColumns(
+            ((ViewChange.ReplaceView) changes[i]).getColumns(), "updates[" + i 
+ "].columns");
+      }
+    }
     validateAlterProperties(ident, 
HasPropertyMetadata::tablePropertiesMetadata, changes);
     boolean isRenameView =
         Arrays.stream(changes).anyMatch(change -> change instanceof 
ViewChange.RenameView);
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestFunctionOperationDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestFunctionOperationDispatcher.java
index 19e459ece6..e3ca02f21f 100644
--- 
a/core/src/test/java/org/apache/gravitino/catalog/TestFunctionOperationDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestFunctionOperationDispatcher.java
@@ -30,7 +30,10 @@ import org.apache.gravitino.Namespace;
 import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.function.FunctionChange;
 import org.apache.gravitino.function.FunctionDefinition;
+import org.apache.gravitino.function.FunctionDefinitions;
+import org.apache.gravitino.function.FunctionImpl;
 import org.apache.gravitino.function.FunctionType;
+import org.apache.gravitino.rel.types.Types;
 import org.apache.gravitino.secret.SecretManager;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.storage.RandomIdGenerator;
@@ -73,7 +76,12 @@ public class TestFunctionOperationDispatcher {
   public void testIcebergSystemSchemaRestriction() {
     NameIdentifier icebergSystemFunc =
         NameIdentifier.of(Namespace.of(METALAKE, ICEBERG_CATALOG, "system"), 
"my_func");
-    FunctionDefinition[] defs = new FunctionDefinition[] 
{mock(FunctionDefinition.class)};
+    FunctionDefinition[] defs = {
+      FunctionDefinitions.of(
+          FunctionChange.EMPTY_PARAMS,
+          Types.StringType.get(),
+          new FunctionImpl[] {mock(FunctionImpl.class)})
+    };
 
     // All write operations on Iceberg system schema should be rejected
     Assertions.assertThrows(
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestWritableDataTypeDispatchers.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestWritableDataTypeDispatchers.java
new file mode 100644
index 0000000000..17acdef41f
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestWritableDataTypeDispatchers.java
@@ -0,0 +1,472 @@
+/*
+ * 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.catalog;
+
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.TestCatalog;
+import org.apache.gravitino.connector.TestCatalogOperations;
+import org.apache.gravitino.function.Function;
+import org.apache.gravitino.function.FunctionChange;
+import org.apache.gravitino.function.FunctionColumn;
+import org.apache.gravitino.function.FunctionDefinition;
+import org.apache.gravitino.function.FunctionDefinitions;
+import org.apache.gravitino.function.FunctionImpl;
+import org.apache.gravitino.function.FunctionImpls;
+import org.apache.gravitino.function.FunctionParam;
+import org.apache.gravitino.function.FunctionParams;
+import org.apache.gravitino.function.FunctionType;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Representation;
+import org.apache.gravitino.rel.SQLRepresentation;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.View;
+import org.apache.gravitino.rel.ViewChange;
+import org.apache.gravitino.rel.expressions.Expression;
+import org.apache.gravitino.rel.expressions.FunctionExpression;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.distributions.Strategy;
+import org.apache.gravitino.rel.expressions.literals.Literal;
+import org.apache.gravitino.rel.expressions.literals.Literals;
+import org.apache.gravitino.rel.expressions.sorts.SortDirection;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.sorts.SortOrders;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.apache.gravitino.rel.indexes.Indexes;
+import org.apache.gravitino.rel.partitions.Partition;
+import org.apache.gravitino.rel.partitions.Partitions;
+import org.apache.gravitino.rel.partitions.RangePartition;
+import org.apache.gravitino.rel.types.Type;
+import org.apache.gravitino.rel.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+public class TestWritableDataTypeDispatchers extends TestOperationDispatcher {
+  private static final NameIdentifier SCHEMA =
+      NameIdentifier.of(metalake, catalog, "writable_types");
+  private static final Map<String, String> PROPERTIES = ImmutableMap.of("k1", 
"v1", "k2", "v2");
+  private static final Type UNPARSED = Types.UnparsedType.of("future_type");
+  private static final Type EXTERNAL = Types.ExternalType.of("jsonb");
+  private static final FunctionImpl[] IMPLS = {
+    FunctionImpls.ofJava(FunctionImpl.RuntimeType.SPARK, "com.example.UDF")
+  };
+  private static final Representation[] REPRESENTATIONS = {
+    SQLRepresentation.builder().withDialect("trino").withSql("SELECT 
1").build()
+  };
+
+  private static TableOperationDispatcher tables;
+  private static ViewOperationDispatcher views;
+  private static PartitionOperationDispatcher partitions;
+  private static FunctionOperationDispatcher functions;
+
+  @BeforeAll
+  public static void initialize() throws IllegalAccessException {
+    SchemaOperationDispatcher schemas =
+        new SchemaOperationDispatcher(catalogManager, entityStore, 
idGenerator, secretManager);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), 
"internalSchemaDispatcher", schemas, true);
+    schemas.createSchema(SCHEMA, "comment", PROPERTIES);
+    tables =
+        new TableOperationDispatcher(
+            catalogManager, entityStore, idGenerator, () -> schemas, 
secretManager);
+    views =
+        new ViewOperationDispatcher(
+            catalogManager, entityStore, idGenerator, () -> schemas, 
secretManager);
+    partitions =
+        new PartitionOperationDispatcher(catalogManager, entityStore, 
idGenerator, secretManager);
+    functions =
+        new FunctionOperationDispatcher(
+            catalogManager, schemas, entityStore, idGenerator, secretManager);
+  }
+
+  @Test
+  public void testCreateTableRejectsTypesBeforeCatalogAccess() {
+    for (Type type :
+        new Type[] {UNPARSED, Types.ListType.of(UNPARSED, true), 
Types.ExternalType.of(" ")}) {
+      assertRejected(
+          () ->
+              tables.createTable(
+                  ident("invalid"), new Column[] {Column.of("c", type)}, null, 
PROPERTIES),
+          "columns[0].type");
+    }
+    Literal<?> literal = Literals.of(null, UNPARSED);
+    assertRejected(
+        () ->
+            tables.createTable(
+                ident("invalid"),
+                new Column[] {Column.of("c", Types.StringType.get(), null, 
literal)},
+                null,
+                PROPERTIES),
+        "columns[0].defaultValue.dataType");
+    Column[] columns = {Column.of("c", Types.StringType.get())};
+    RangePartition range = Partitions.range("p", literal, 
Literals.stringLiteral("a"), null);
+    assertRejected(
+        () ->
+            tables.createTable(
+                ident("invalid"),
+                columns,
+                null,
+                PROPERTIES,
+                new Transform[] {
+                  Transforms.range(new String[] {"c"}, new RangePartition[] 
{range})
+                },
+                Distributions.NONE,
+                new SortOrder[0],
+                Indexes.EMPTY_INDEXES),
+        "partitioning[0].assignments[0].upper.dataType");
+    assertRejected(
+        () ->
+            tables.createTable(
+                ident("invalid"),
+                columns,
+                null,
+                PROPERTIES,
+                new Transform[] {Transforms.apply("f", new Expression[] 
{literal})},
+                Distributions.NONE,
+                new SortOrder[0],
+                Indexes.EMPTY_INDEXES),
+        "partitioning[0].funcArgs[0].dataType");
+    assertRejected(
+        () ->
+            tables.createTable(
+                ident("invalid"),
+                columns,
+                null,
+                PROPERTIES,
+                new Transform[0],
+                Distributions.of(Strategy.HASH, 2, literal),
+                new SortOrder[0],
+                Indexes.EMPTY_INDEXES),
+        "distribution.funcArgs[0].dataType");
+    assertRejected(
+        () ->
+            tables.createTable(
+                ident("invalid"),
+                columns,
+                null,
+                PROPERTIES,
+                new Transform[0],
+                Distributions.NONE,
+                new SortOrder[] {SortOrders.of(literal, 
SortDirection.ASCENDING)},
+                Indexes.EMPTY_INDEXES),
+        "sortOrders[0].sortTerm.dataType");
+    verifyNoInteractions(entityStore);
+  }
+
+  @Test
+  public void testNullTableInputsRejectedBeforeCatalogAccess() {
+    for (Column[] columns : new Column[][] {null, new Column[] {null}}) {
+      assertRejected(
+          () -> tables.createTable(ident("invalid_table"), columns, null, 
PROPERTIES),
+          columns == null ? "\"columns\"" : "columns[0]");
+    }
+    Column[] columns = {Column.of("c", Types.StringType.get())};
+    assertRejected(
+        () ->
+            tables.createTable(
+                ident("invalid_table"),
+                columns,
+                null,
+                PROPERTIES,
+                new Transform[0],
+                Distributions.hash(2, (Expression[]) null),
+                SortOrders.NONE,
+                Indexes.EMPTY_INDEXES),
+        "distribution.funcArgs");
+    assertRejected(
+        () ->
+            tables.createTable(
+                ident("invalid_table"),
+                columns,
+                null,
+                PROPERTIES,
+                new Transform[0],
+                Distributions.NONE,
+                new SortOrder[] {null},
+                Indexes.EMPTY_INDEXES),
+        "sortOrders[0]");
+    verifyNoInteractions(entityStore);
+  }
+
+  @Test
+  public void testTableChangesValidateEntireBatchBeforeMutation() {
+    NameIdentifier ident = ident("alter_types");
+    tables.createTable(ident, new Column[] {Column.of("c", EXTERNAL)}, 
"original", PROPERTIES);
+    for (TableChange invalid :
+        new TableChange[] {
+          TableChange.addColumn(new String[] {"new"}, 
Types.ListType.of(UNPARSED, true)),
+          TableChange.updateColumnType(new String[] {"c"}, 
Types.ExternalType.of(" ")),
+          TableChange.addColumn(
+              new String[] {"new"},
+              EXTERNAL,
+              null,
+              TableChange.ColumnPosition.defaultPos(),
+              true,
+              false,
+              Literals.of("x", UNPARSED)),
+          TableChange.updateColumnDefaultValue(
+              new String[] {"c"}, FunctionExpression.of("f", Literals.of(null, 
UNPARSED)))
+        }) {
+      assertRejected(
+          () -> tables.alterTable(ident, TableChange.setProperty("k1", 
"changed"), invalid),
+          "updates[1]");
+      Table unchanged = tables.loadTable(ident);
+      Assertions.assertEquals("v1", unchanged.properties().get("k1"));
+      Assertions.assertEquals(1, unchanged.columns().length);
+      Assertions.assertEquals(EXTERNAL, unchanged.columns()[0].dataType());
+    }
+    Table updated =
+        tables.alterTable(
+            ident,
+            TableChange.addColumn(new String[] {"new"}, 
Types.ListType.of(EXTERNAL, true)),
+            TableChange.updateColumnType(new String[] {"c"}, 
Types.StringType.get()));
+    Assertions.assertEquals(Types.StringType.get(), 
updated.columns()[0].dataType());
+    Assertions.assertEquals(Types.ListType.of(EXTERNAL, true), 
updated.columns()[1].dataType());
+  }
+
+  @Test
+  public void testLegacyTableCanBeImportedAndManaged() {
+    NameIdentifier ident = ident("legacy_table");
+    TestCatalog testCatalog =
+        (TestCatalog)
+            catalogManager.loadCatalogAndWrap(NameIdentifier.of(metalake, 
catalog)).catalog();
+    ((TestCatalogOperations) testCatalog.ops())
+        .createTable(
+            ident,
+            new Column[] {Column.of("legacy", UNPARSED)},
+            "original",
+            PROPERTIES,
+            new Transform[0],
+            Distributions.NONE,
+            new SortOrder[0],
+            Indexes.EMPTY_INDEXES);
+    Assertions.assertEquals(UNPARSED, 
tables.loadTable(ident).columns()[0].dataType());
+    Table updated =
+        tables.alterTable(
+            ident,
+            TableChange.updateComment("updated"),
+            TableChange.updateColumnComment(new String[] {"legacy"}, 
"updated"),
+            TableChange.renameColumn(new String[] {"legacy"}, "renamed"),
+            TableChange.addColumn(new String[] {"native"}, 
Types.IntegerType.get()));
+    Assertions.assertEquals("updated", updated.columns()[0].comment());
+    Assertions.assertEquals("renamed", updated.columns()[0].name());
+    Assertions.assertEquals(UNPARSED, updated.columns()[0].dataType());
+    Assertions.assertTrue(tables.dropTable(ident));
+  }
+
+  @Test
+  public void testViewTypesRejectedBeforeCatalogAccess() {
+    for (Column column :
+        new Column[] {
+          null,
+          Column.of("c", UNPARSED),
+          Column.of("c", Types.ListType.of(UNPARSED, true)),
+          Column.of("c", Types.ExternalType.of(" ")),
+          Column.of("c", Types.StringType.get(), null, Literals.of(null, 
UNPARSED))
+        }) {
+      assertRejected(
+          () ->
+              views.createView(
+                  ident("invalid_view"),
+                  null,
+                  new Column[] {column},
+                  REPRESENTATIONS,
+                  null,
+                  null,
+                  PROPERTIES),
+          "columns[0]");
+      assertRejected(
+          () ->
+              views.alterView(
+                  ident("invalid_view"),
+                  ViewChange.setProperty("k1", "changed"),
+                  ViewChange.replaceView(new Column[] {column}, 
REPRESENTATIONS, null, null, null)),
+          "updates[1].columns[0]");
+    }
+    verifyNoInteractions(entityStore);
+  }
+
+  @Test
+  public void testViewReplacementValidatesBeforeMutation() {
+    NameIdentifier ident = ident("view_types");
+    View created = views.createView(ident, null, null, REPRESENTATIONS, null, 
null, PROPERTIES);
+    Assertions.assertEquals(0, created.columns().length);
+    Type externalList = Types.ListType.of(EXTERNAL, true);
+    View replaced =
+        views.alterView(
+            ident,
+            ViewChange.replaceView(
+                new Column[] {Column.of("c", externalList)}, REPRESENTATIONS, 
null, null, null));
+    Assertions.assertEquals(externalList, replaced.columns()[0].dataType());
+
+    assertRejected(
+        () ->
+            views.alterView(
+                ident,
+                ViewChange.setProperty("k1", "changed"),
+                ViewChange.replaceView(
+                    new Column[] {Column.of("c", UNPARSED)}, REPRESENTATIONS, 
null, null, null)),
+        "updates[1].columns[0].type");
+    View unchanged = views.loadView(ident);
+    Assertions.assertEquals("v1", unchanged.properties().get("k1"));
+    Assertions.assertEquals(externalList, unchanged.columns()[0].dataType());
+    Assertions.assertTrue(views.dropView(ident));
+  }
+
+  @Test
+  public void testLegacyViewCanBeImportedAndManaged() {
+    NameIdentifier ident = ident("legacy_view");
+    TestCatalog testCatalog =
+        (TestCatalog)
+            catalogManager.loadCatalogAndWrap(NameIdentifier.of(metalake, 
catalog)).catalog();
+    ((TestCatalogOperations) testCatalog.ops())
+        .createView(
+            ident,
+            null,
+            new Column[] {Column.of("legacy", UNPARSED)},
+            REPRESENTATIONS,
+            null,
+            null,
+            PROPERTIES);
+    Assertions.assertEquals(UNPARSED, 
views.loadView(ident).columns()[0].dataType());
+    View updated =
+        views.alterView(
+            ident, ViewChange.setProperty("k1", "changed"), 
ViewChange.removeProperty("k2"));
+    Assertions.assertEquals("changed", updated.properties().get("k1"));
+    Assertions.assertFalse(updated.properties().containsKey("k2"));
+    Assertions.assertEquals(UNPARSED, updated.columns()[0].dataType());
+    View renamed = views.alterView(ident, 
ViewChange.rename("renamed_legacy_view"));
+    Assertions.assertEquals("renamed_legacy_view", renamed.name());
+    Assertions.assertEquals(UNPARSED, renamed.columns()[0].dataType());
+    Assertions.assertTrue(views.dropView(ident("renamed_legacy_view")));
+  }
+
+  @Test
+  public void testPartitionTypesRejectedBeforeCatalogAccess() {
+    Literal<?> literal = Literals.of(null, UNPARSED);
+    for (Partition partition :
+        new Partition[] {
+          Partitions.identity("p", new String[][] {{"c"}}, new Literal<?>[] 
{literal}, null),
+          Partitions.list("p", new Literal<?>[][] {{literal}}, null),
+          Partitions.range("p", literal, Literals.stringLiteral("a"), null),
+          Partitions.range("p", Literals.stringLiteral("z"), literal, null)
+        }) {
+      assertRejected(() -> partitions.addPartition(ident("missing"), 
partition), "partition.");
+    }
+    verifyNoInteractions(entityStore);
+  }
+
+  @Test
+  public void testFunctionDefinitionsRejectedBeforeStorageAccess() {
+    FunctionParam[] invalidParam = {FunctionParams.of("x", UNPARSED)};
+    FunctionParam[] invalidDefault = {
+      FunctionParams.of(
+          "x",
+          Types.StringType.get(),
+          null,
+          FunctionExpression.of("f", Literals.of(null, UNPARSED)))
+    };
+    for (FunctionDefinition definition :
+        new FunctionDefinition[] {
+          FunctionDefinitions.of(invalidParam, Types.StringType.get(), IMPLS),
+          FunctionDefinitions.of(invalidDefault, Types.StringType.get(), 
IMPLS),
+          FunctionDefinitions.of(new FunctionParam[] {null}, 
Types.StringType.get(), IMPLS),
+          FunctionDefinitions.of(new FunctionParam[0], new FunctionColumn[] 
{null}, IMPLS),
+          FunctionDefinitions.of(new FunctionParam[0], 
Types.ListType.of(UNPARSED, true), IMPLS),
+          FunctionDefinitions.of(
+              new FunctionParam[0],
+              new FunctionColumn[] {FunctionColumn.of("x", 
Types.ExternalType.of(" "), null)},
+              IMPLS)
+        }) {
+      assertRejected(
+          () ->
+              functions.registerFunction(
+                  ident("invalid_function"),
+                  null,
+                  FunctionType.SCALAR,
+                  true,
+                  new FunctionDefinition[] {definition}),
+          "definitions[0]");
+      assertRejected(
+          () ->
+              functions.alterFunction(
+                  ident("invalid_function"),
+                  FunctionChange.updateComment("new"),
+                  FunctionChange.addDefinition(definition)),
+          "updates[1].definition");
+    }
+    verifyNoInteractions(entityStore);
+  }
+
+  @Test
+  public void testExternalFunctionAndLegacySelectors() {
+    FunctionParam[] externalParams = {FunctionParams.of("x", EXTERNAL)};
+    FunctionDefinition external = FunctionDefinitions.of(externalParams, 
EXTERNAL, IMPLS);
+    Function created =
+        functions.registerFunction(
+            ident("external_function"),
+            null,
+            FunctionType.SCALAR,
+            true,
+            new FunctionDefinition[] {external});
+    Assertions.assertEquals(EXTERNAL, created.definitions()[0].returnType());
+
+    NameIdentifier legacyIdent = ident("legacy_function");
+    FunctionParam[] legacyParams = {FunctionParams.of("x", UNPARSED)};
+    FunctionDefinition legacy = FunctionDefinitions.of(legacyParams, UNPARSED, 
IMPLS);
+    // Seed stored metadata without going through the new-write dispatcher.
+    new ManagedFunctionOperations(entityStore, idGenerator)
+        .registerFunction(
+            legacyIdent, null, FunctionType.SCALAR, true, new 
FunctionDefinition[] {legacy});
+    Assertions.assertEquals(
+        UNPARSED, 
functions.getFunction(legacyIdent).definitions()[0].returnType());
+    functions.alterFunction(legacyIdent, 
FunctionChange.addDefinition(external));
+    FunctionImpl trino = FunctionImpls.ofJava(FunctionImpl.RuntimeType.TRINO, 
"com.example.UDF");
+    Function added =
+        functions.alterFunction(legacyIdent, 
FunctionChange.addImpl(legacyParams, trino));
+    Assertions.assertEquals(2, added.definitions()[0].impls().length);
+    functions.alterFunction(
+        legacyIdent,
+        FunctionChange.updateImpl(legacyParams, 
FunctionImpl.RuntimeType.TRINO, trino));
+    functions.alterFunction(
+        legacyIdent, FunctionChange.removeImpl(legacyParams, 
FunctionImpl.RuntimeType.TRINO));
+    Function remaining =
+        functions.alterFunction(legacyIdent, 
FunctionChange.removeDefinition(legacyParams));
+    Assertions.assertEquals(1, remaining.definitions().length);
+    Assertions.assertEquals(EXTERNAL, remaining.definitions()[0].returnType());
+  }
+
+  private static NameIdentifier ident(String name) {
+    return NameIdentifier.of(metalake, catalog, SCHEMA.name(), name);
+  }
+
+  private static void assertRejected(Executable operation, String path) {
+    IllegalArgumentException exception =
+        Assertions.assertThrows(IllegalArgumentException.class, operation);
+    Assertions.assertTrue(exception.getMessage().contains(path), 
exception.getMessage());
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
index 1a22aa0600..c34686a259 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
@@ -77,6 +77,7 @@ import org.apache.gravitino.rel.expressions.Expression;
 import org.apache.gravitino.rel.expressions.NamedReference;
 import org.apache.gravitino.rel.expressions.distributions.Distributions;
 import org.apache.gravitino.rel.expressions.distributions.Strategy;
+import org.apache.gravitino.rel.expressions.literals.Literal;
 import org.apache.gravitino.rel.expressions.literals.Literals;
 import org.apache.gravitino.rel.expressions.sorts.SortDirection;
 import org.apache.gravitino.rel.expressions.sorts.SortOrder;
@@ -251,6 +252,38 @@ public class TestPOConverters {
     assertEquals(expectedColumn1.comment(), convertedColumn1.comment());
   }
 
+  @Test
+  public void testFromColumnPOWithLegacyNullDefault() throws 
JsonProcessingException {
+    Type legacyType = Types.UnparsedType.of("future_type");
+    ColumnPO columnPO =
+        createColumnPO(
+            1L,
+            "legacy",
+            0,
+            1L,
+            1L,
+            1L,
+            1L,
+            legacyType,
+            null,
+            true,
+            false,
+            Literals.of(null, legacyType),
+            ColumnPO.ColumnOpType.CREATE);
+
+    Literal<?> storedDefault =
+        (Literal<?>)
+            JsonUtils.anyFieldMapper().readValue(columnPO.getDefaultValue(), 
Expression.class);
+    assertEquals(legacyType, storedDefault.dataType());
+    assertNull(storedDefault.value());
+
+    // Loading existing metadata must preserve the converter's null 
normalization without applying
+    // the validation that rejects this declared type on new writes.
+    ColumnEntity convertedColumn = POConverters.fromColumnPO(columnPO);
+    assertEquals(legacyType, convertedColumn.dataType());
+    assertEquals(Literals.NULL, convertedColumn.defaultValue());
+  }
+
   @Test
   public void testFromTableColumnPOs() throws JsonProcessingException {
     TablePO tablePO = createTablePO(1L, "test", 1L, 1L, 1L);
diff --git a/docs/expression.md b/docs/expression.md
index 8962497020..c11914e4f0 100644
--- a/docs/expression.md
+++ b/docs/expression.md
@@ -46,6 +46,13 @@ NamedReference field = NamedReference.field("student");
 ## Literal
 
 Literal is a constant value.
+
+When writing table or function defaults, partition values, partitioning, 
distribution, or sort
+expressions, each literal must declare a recognized Gravitino type or an 
explicit
+[external type](./tables-and-views.md#external-type) with a nonblank 
`catalogString`.
+This applies recursively inside function arguments and nested types, even when 
the literal value
+is null. `UnparsedType` is retained for reading existing metadata and cannot 
be used for these writes.
+
 The following is an example of creating a literal expression, demonstrating 
how to create a `NULL` literal and three different data types of literal 
expressions for the value `1024`.
 
 <Tabs groupId='language' queryString>
@@ -143,7 +150,9 @@ FunctionExpression[] functionExpressions =
 
 ## Unparsed Expression
 
-Unparsed expression is a special type of expression, serves exclusively for 
presenting the default value of a column when it's unsolvable.
+An unparsed expression preserves SQL text that Gravitino cannot represent as a 
structured expression.
+It can be used for column defaults when supported by the target catalog. 
Unlike `UnparsedType`,
+it does not declare a data type and remains accepted in write requests.
 The following shows the data structure of an unparsed expression in JSON and 
Java, enabling easy retrieval of its value.
 
 <Tabs groupId='language' queryString>
diff --git a/docs/open-api/expression.yaml b/docs/open-api/expression.yaml
index eb0a37c29c..62e46d5bf0 100644
--- a/docs/open-api/expression.yaml
+++ b/docs/open-api/expression.yaml
@@ -24,12 +24,14 @@ components:
         - $ref: "#/components/schemas/Literal"
         - $ref: "#/components/schemas/Field"
         - $ref: "#/components/schemas/Function"
+        - $ref: "#/components/schemas/UnparsedExpression"
       discriminator:
         propertyName: type
         mapping:
           literal: "#/components/schemas/Literal"
           field: "#/components/schemas/Field"
           function: "#/components/schemas/Function"
+          unparsed: "#/components/schemas/UnparsedExpression"
 
     Literal:
       type: object
@@ -46,6 +48,7 @@ components:
         value:
           type: string
           description: The string format of the literal value
+          nullable: true
 
     Field:
       type: object
@@ -78,4 +81,53 @@ components:
           type: array
           description: The arguments of the function
           items:
-            $ref: "#/components/schemas/FunctionArg"
\ No newline at end of file
+            $ref: "#/components/schemas/FunctionArg"
+
+    UnparsedExpression:
+      type: object
+      description: >-
+        An opaque SQL expression passed to the catalog. Unlike UnparsedType,
+        this expression does not declare a data type and is allowed on writes.
+      required:
+        - type
+        - unparsedExpression
+      properties:
+        type:
+          type: string
+          enum:
+            - "unparsed"
+        unparsedExpression:
+          type: string
+          description: The SQL expression text
+
+    WritableFunctionArg:
+      oneOf:
+        - $ref: "#/components/schemas/WritableLiteral"
+        - $ref: "#/components/schemas/Field"
+        - $ref: "#/components/schemas/WritableFunction"
+        - $ref: "#/components/schemas/UnparsedExpression"
+      discriminator:
+        propertyName: type
+        mapping:
+          literal: "#/components/schemas/WritableLiteral"
+          field: "#/components/schemas/Field"
+          function: "#/components/schemas/WritableFunction"
+          unparsed: "#/components/schemas/UnparsedExpression"
+
+    WritableLiteral:
+      allOf:
+        - $ref: "#/components/schemas/Literal"
+        - type: object
+          properties:
+            dataType:
+              $ref: "./datatype.yaml#/components/schemas/WritableDataType"
+
+    WritableFunction:
+      allOf:
+        - $ref: "#/components/schemas/Function"
+        - type: object
+          properties:
+            funcArgs:
+              type: array
+              items:
+                $ref: "#/components/schemas/WritableFunctionArg"
diff --git a/docs/open-api/functions.yaml b/docs/open-api/functions.yaml
index 5fde235573..6dc8533477 100644
--- a/docs/open-api/functions.yaml
+++ b/docs/open-api/functions.yaml
@@ -314,7 +314,7 @@ components:
           description: The comment of the parameter
           nullable: true
         defaultValue:
-          $ref: "./expression.yaml#/components/schemas/FunctionArg"
+          $ref: "./expression.yaml#/components/schemas/WritableFunctionArg"
           description: The default value expression of the parameter
 
     WritableFunctionColumn:
diff --git a/docs/open-api/partitioning.yaml b/docs/open-api/partitioning.yaml
index 002db2a2f1..04b9f96d7e 100644
--- a/docs/open-api/partitioning.yaml
+++ b/docs/open-api/partitioning.yaml
@@ -198,4 +198,60 @@ components:
           type: array
           description: The arguments of the partitioning function
           items:
-            $ref: "./expression.yaml#/components/schemas/FunctionArg"
\ No newline at end of file
+            $ref: "./expression.yaml#/components/schemas/FunctionArg"
+
+    WritablePartitioningSpec:
+      oneOf:
+        - $ref: "#/components/schemas/IdentityPartitioning"
+        - $ref: "#/components/schemas/YearPartitioning"
+        - $ref: "#/components/schemas/MonthPartitioning"
+        - $ref: "#/components/schemas/DayPartitioning"
+        - $ref: "#/components/schemas/HourPartitioning"
+        - $ref: "#/components/schemas/BucketPartitioning"
+        - $ref: "#/components/schemas/TruncatePartitioning"
+        - $ref: "#/components/schemas/WritableListPartitioning"
+        - $ref: "#/components/schemas/WritableRangePartitioning"
+        - $ref: "#/components/schemas/WritableFunctionPartitioning"
+      discriminator:
+        propertyName: strategy
+        mapping:
+          identity: "#/components/schemas/IdentityPartitioning"
+          year: "#/components/schemas/YearPartitioning"
+          month: "#/components/schemas/MonthPartitioning"
+          day: "#/components/schemas/DayPartitioning"
+          hour: "#/components/schemas/HourPartitioning"
+          bucket: "#/components/schemas/BucketPartitioning"
+          truncate: "#/components/schemas/TruncatePartitioning"
+          list: "#/components/schemas/WritableListPartitioning"
+          range: "#/components/schemas/WritableRangePartitioning"
+          function: "#/components/schemas/WritableFunctionPartitioning"
+
+    WritableListPartitioning:
+      allOf:
+        - $ref: "#/components/schemas/ListPartitioning"
+        - type: object
+          properties:
+            assignments:
+              type: array
+              items:
+                $ref: 
"./partitions.yaml#/components/schemas/WritableListPartition"
+
+    WritableRangePartitioning:
+      allOf:
+        - $ref: "#/components/schemas/RangePartitioning"
+        - type: object
+          properties:
+            assignments:
+              type: array
+              items:
+                $ref: 
"./partitions.yaml#/components/schemas/WritableRangePartition"
+
+    WritableFunctionPartitioning:
+      allOf:
+        - $ref: "#/components/schemas/FunctionPartitioning"
+        - type: object
+          properties:
+            funcArgs:
+              type: array
+              items:
+                $ref: 
"./expression.yaml#/components/schemas/WritableFunctionArg"
diff --git a/docs/open-api/partitions.yaml b/docs/open-api/partitions.yaml
index 9ac7aa4d25..45fdd54dd0 100644
--- a/docs/open-api/partitions.yaml
+++ b/docs/open-api/partitions.yaml
@@ -175,6 +175,50 @@ components:
           range: "#/components/schemas/RangePartition"
           list: "#/components/schemas/ListPartition"
 
+    WritablePartitionSpec:
+      oneOf:
+        - $ref: "#/components/schemas/WritableIdentityPartition"
+        - $ref: "#/components/schemas/WritableRangePartition"
+        - $ref: "#/components/schemas/WritableListPartition"
+      discriminator:
+        propertyName: type
+        mapping:
+          identity: "#/components/schemas/WritableIdentityPartition"
+          range: "#/components/schemas/WritableRangePartition"
+          list: "#/components/schemas/WritableListPartition"
+
+    WritableIdentityPartition:
+      allOf:
+        - $ref: "#/components/schemas/IdentityPartition"
+        - type: object
+          properties:
+            values:
+              type: array
+              items:
+                $ref: "./expression.yaml#/components/schemas/WritableLiteral"
+
+    WritableRangePartition:
+      allOf:
+        - $ref: "#/components/schemas/RangePartition"
+        - type: object
+          properties:
+            upper:
+              $ref: "./expression.yaml#/components/schemas/WritableLiteral"
+            lower:
+              $ref: "./expression.yaml#/components/schemas/WritableLiteral"
+
+    WritableListPartition:
+      allOf:
+        - $ref: "#/components/schemas/ListPartition"
+        - type: object
+          properties:
+            lists:
+              type: array
+              items:
+                type: array
+                items:
+                  $ref: "./expression.yaml#/components/schemas/WritableLiteral"
+
     AddPartitionsRequest:
       type: object
       required:
@@ -184,7 +228,7 @@ components:
           type: array
           description: The partitions to add
           items:
-            $ref: "#/components/schemas/PartitionSpec"
+            $ref: "#/components/schemas/WritablePartitionSpec"
 
     PartitionNameListResponse:
       type: object
diff --git a/docs/open-api/tables.yaml b/docs/open-api/tables.yaml
index efe713c2d3..9327387cd0 100644
--- a/docs/open-api/tables.yaml
+++ b/docs/open-api/tables.yaml
@@ -193,7 +193,7 @@ components:
           type: array
           description: A list of columns
           items:
-            $ref: "#/components/schemas/Column"
+            $ref: "#/components/schemas/WritableColumn"
         comment:
           type: string
           description: The comment of the table
@@ -211,16 +211,16 @@ components:
           nullable: true
           default: [ ]
           items:
-            $ref: "#/components/schemas/SortOrder"
+            $ref: "#/components/schemas/WritableSortOrder"
         distribution:
-          $ref: "#/components/schemas/Distribution"
+          $ref: "#/components/schemas/WritableDistribution"
         partitioning:
           type: array
           description: partitioning of the table data
           nullable: true
           default: [ ]
           items:
-            $ref: "./partitioning.yaml#/components/schemas/PartitioningSpec"
+            $ref: 
"./partitioning.yaml#/components/schemas/WritablePartitioningSpec"
         indexes:
           type: array
           description: Indexes of the table
@@ -229,6 +229,22 @@ components:
           items:
             $ref: "./indexes.yaml#/components/schemas/IndexSpec"
 
+      example:
+        name: events
+        columns:
+          - name: id
+            type: integer
+            defaultValue:
+              type: literal
+              dataType: integer
+              value: null
+          - name: created_at
+            type: timestamp
+            defaultValue:
+              type: unparsed
+              unparsedExpression: CURRENT_TIMESTAMP
+        distribution: null
+
     Table:
       type: object
       description: A table object
@@ -380,6 +396,46 @@ components:
           nullable: true
           default: false
 
+    WritableColumn:
+      allOf:
+        - $ref: "#/components/schemas/Column"
+        - type: object
+          properties:
+            type:
+              $ref: "./datatype.yaml#/components/schemas/WritableDataType"
+            defaultValue:
+              $ref: "#/components/schemas/WritableColumnDefaultValue"
+
+    WritableColumnDefaultValue:
+      allOf:
+        - $ref: "./expression.yaml#/components/schemas/WritableFunctionArg"
+      description: The default value of a new or updated column, with writable 
literal types
+
+    WritableDistribution:
+      allOf:
+        - $ref: "#/components/schemas/Distribution"
+        - type: object
+          nullable: true
+          properties:
+            funcArgs:
+              type: array
+              nullable: true
+              items:
+                $ref: 
"./expression.yaml#/components/schemas/WritableFunctionArg"
+
+      example:
+        strategy: even
+        number: 0
+        funcArgs: null
+
+    WritableSortOrder:
+      allOf:
+        - $ref: "#/components/schemas/SortOrder"
+        - type: object
+          properties:
+            sortTerm:
+              $ref: "./expression.yaml#/components/schemas/WritableFunctionArg"
+
     TableUpdatesRequest:
       type: object
       required:
@@ -517,7 +573,7 @@ components:
           $ref: "#/components/schemas/FieldName"
           description: The name of the column to add
         type:
-          $ref: "./datatype.yaml#/components/schemas/DataType"
+          $ref: "./datatype.yaml#/components/schemas/WritableDataType"
           description: The type of the column to add
         comment:
           type: string
@@ -539,7 +595,7 @@ components:
           default: false
         defaultValue:
           allOf:
-            - $ref: "#/components/schemas/ColumnDefaultValue"
+            - $ref: "#/components/schemas/WritableColumnDefaultValue"
           description: The default value of the column to add
       example: {
         "@type": "addColumn",
@@ -594,7 +650,7 @@ components:
           $ref: "#/components/schemas/FieldName"
           description: The name of the column to update
         newType:
-          $ref: "./datatype.yaml#/components/schemas/DataType"
+          $ref: "./datatype.yaml#/components/schemas/WritableDataType"
           description: The new type of the column
       example: {
         "@type": "updateColumnType",
@@ -688,7 +744,7 @@ components:
           $ref: "#/components/schemas/FieldName"
           description: The name of the column to update
         newDefaultValue:
-          $ref: "#/components/schemas/ColumnDefaultValue"
+          $ref: "#/components/schemas/WritableColumnDefaultValue"
           description: The new default value of the column 
       example: {
         "@type": "updateColumnDefaultValue",
diff --git a/docs/open-api/views.yaml b/docs/open-api/views.yaml
index 2fd0bb0519..ebc7d8d4e7 100644
--- a/docs/open-api/views.yaml
+++ b/docs/open-api/views.yaml
@@ -298,7 +298,7 @@ components:
           type: array
           description: The output columns of the view
           items:
-            $ref: "./tables.yaml#/components/schemas/Column"
+            $ref: "./tables.yaml#/components/schemas/WritableColumn"
         representations:
           type: array
           description: The representations of the view, at least one is 
required
@@ -410,6 +410,13 @@ components:
       description: |
         Atomically replaces the body of a view. Columns, representations, 
default catalog, default
         schema and comment are all replaced. The view name and properties are 
not affected.
+
+        All supplied column types and literal types in defaults must satisfy 
the writable type
+        requirements, including nested types and types copied unchanged from 
the current view.
+        UnparsedType is rejected even when only the comment or SQL 
representation changes. Use
+        recognized native types or explicit ExternalType values with nonblank 
catalog strings.
+        Reads, imports, renames and property changes remain supported for 
existing views with
+        unresolved types.
       required:
         - "@type"
         - representations
@@ -422,7 +429,7 @@ components:
           type: array
           description: The new output columns of the view
           items:
-            $ref: "./tables.yaml#/components/schemas/Column"
+            $ref: "./tables.yaml#/components/schemas/WritableColumn"
         representations:
           type: array
           description: The new representations of the view, at least one is 
required
diff --git a/docs/tables-and-views.md b/docs/tables-and-views.md
index 108a4431e0..ba767064b1 100644
--- a/docs/tables-and-views.md
+++ b/docs/tables-and-views.md
@@ -104,6 +104,10 @@ written. Support is connector-specific.
 An external type represents a catalog type that is not part of the Gravitino 
type system. It keeps
 the external catalog's type string so clients can inspect it without losing 
information.
 
+When creating a table, adding a column, or changing a column's type, use a 
native Gravitino type
+or an explicit external type with a nonblank `catalogString`. This rule also 
applies to nested
+list, map, struct, and union types. The target catalog still determines 
whether it supports the type.
+
 ```json
 {
   "type": "external",
@@ -120,6 +124,11 @@ String typeString = ((ExternalType) type).catalogString();
 An unparsed type preserves forward compatibility when a client does not 
recognize a type returned
 by the server. The client retains the serialized value instead of failing 
deserialization.
 
+`UnparsedType` is not accepted for new or replaced column types, or for 
literal types in newly
+written expressions. Existing metadata containing it can still be read, 
imported, renamed, or
+annotated without replacing its type. Gravitino does not automatically convert 
an unparsed type
+into an external type.
+
 ```json
 {
   "type": "unparsed",
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestFunctionOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestFunctionOperations.java
index 17957719c4..fafcbc7ae1 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestFunctionOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestFunctionOperations.java
@@ -73,6 +73,7 @@ import org.glassfish.jersey.server.ResourceConfig;
 import org.glassfish.jersey.test.TestProperties;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
 
 public class TestFunctionOperations extends BaseOperationsTest {
 
@@ -123,6 +124,30 @@ public class TestFunctionOperations extends 
BaseOperationsTest {
     return resourceConfig;
   }
 
+  @Test
+  public void testRejectUnresolvedParameterDefaultBeforeDispatch() {
+    String request =
+        """
+        
{"name":"f","functionType":"SCALAR","deterministic":true,"definitions":[{
+          "parameters":[{"name":"x","dataType":"string","defaultValue":{
+            "type":"literal","dataType":"future_type","value":null}}],
+          "returnType":"string"}]}
+        """;
+    try (Response response =
+        target(functionPath())
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.json(request))) {
+      Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      Assertions.assertTrue(
+          response
+              .readEntity(ErrorResponse.class)
+              .getMessage()
+              .contains("definitions[0].parameters[0].defaultValue.dataType"));
+    }
+    Mockito.verifyNoInteractions(functionDispatcher);
+  }
+
   @Test
   public void testListFunctions() {
     NameIdentifier funcId1 = NameIdentifierUtil.ofFunction(metalake, catalog, 
schema, "func1");
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestPartitionOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestPartitionOperations.java
index 2c27c9c7db..01bf77228b 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestPartitionOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestPartitionOperations.java
@@ -136,6 +136,28 @@ public class TestPartitionOperations extends JerseyTest {
         metalake, catalog, schema, table);
   }
 
+  @Test
+  public void testRejectUnresolvedNullPartitionLiteralBeforeDispatch() {
+    String request =
+        """
+        {"partitions":[{"type":"identity","name":"p","fieldNames":[["c"]],
+          
"values":[{"type":"literal","dataType":"future_type","value":null}]}]}
+        """;
+    try (Response response =
+        target(partitionPath(metalake, catalog, schema, table))
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.json(request))) {
+      Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      Assertions.assertTrue(
+          response
+              .readEntity(ErrorResponse.class)
+              .getMessage()
+              .contains("partitions[0].values[0].dataType"));
+    }
+    Mockito.verifyNoInteractions(dispatcher);
+  }
+
   @Test
   public void testListPartitionNames() {
     when(dispatcher.listPartitionNames(any())).thenReturn(partitionNames);
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestTableOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestTableOperations.java
index 9864b208d5..5d14f8ea50 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestTableOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestTableOperations.java
@@ -154,6 +154,73 @@ public class TestTableOperations extends 
BaseOperationsTest {
     return resourceConfig;
   }
 
+  @Test
+  public void testRejectUnresolvedTypesBeforeDispatch() {
+    String[] columns = {
+      "{\"name\":\"c\",\"type\":\"future_type\"}",
+      "{\"name\":\"c\",\"type\":{\"type\":\"external\",\"catalogString\":\" 
\"}}",
+      
"{\"name\":\"c\",\"type\":\"string\",\"defaultValue\":{\"type\":\"literal\",\"dataType\":\"future_type\",\"value\":null}}"
+    };
+    for (String column : columns) {
+      String json = "{\"name\":\"t\",\"columns\":[" + column + "]}";
+      try (Response response =
+          target(tablePath(metalake, catalog, schema))
+              .request(MediaType.APPLICATION_JSON_TYPE)
+              .accept("application/vnd.gravitino.v1+json")
+              .post(Entity.json(json))) {
+        Assertions.assertEquals(Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+        Assertions.assertTrue(
+            
response.readEntity(ErrorResponse.class).getMessage().contains("columns[0]"));
+      }
+    }
+    String update =
+        """
+        {"updates":[{"@type":"updateColumnDefaultValue","fieldName":["c"],
+          "newDefaultValue":{"type":"function","funcName":"f","funcArgs":[
+            {"type":"literal","dataType":"future_type","value":null}]}}]}
+        """;
+    try (Response response =
+        target(tablePath(metalake, catalog, schema) + "t")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .put(Entity.json(update))) {
+      Assertions.assertEquals(Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      Assertions.assertTrue(
+          response
+              .readEntity(ErrorResponse.class)
+              .getMessage()
+              .contains("updates[0].newDefaultValue.funcArgs[0].dataType"));
+    }
+    Mockito.verifyNoInteractions(dispatcher);
+  }
+
+  @Test
+  public void testRejectNullTableInputsBeforeDispatch() {
+    String[][] invalidRequests = {
+      {"{\"name\":\"t\",\"columns\":null}", "columns"},
+      {"{\"name\":\"t\",\"columns\":[null]}", "columns[0]"},
+      {
+        
"{\"name\":\"t\",\"columns\":[{\"name\":\"c\",\"type\":\"string\"}],\"sortOrders\":[null]}",
+        "sortOrders[0]"
+      }
+    };
+    for (String[] invalidRequest : invalidRequests) {
+      try (Response response =
+          target(tablePath(metalake, catalog, schema))
+              .request(MediaType.APPLICATION_JSON_TYPE)
+              .accept("application/vnd.gravitino.v1+json")
+              .post(Entity.json(invalidRequest[0]))) {
+        Assertions.assertEquals(Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+        Assertions.assertTrue(
+            response
+                .readEntity(ErrorResponse.class)
+                .getMessage()
+                .contains("\"" + invalidRequest[1] + "\" field is required and 
cannot be null"));
+      }
+    }
+    Mockito.verifyNoInteractions(dispatcher);
+  }
+
   @Test
   public void testListTables() {
     NameIdentifier table1 = NameIdentifier.of(metalake, catalog, schema, 
"table1");
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestViewOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestViewOperations.java
index 215ed2be8b..d0971646a5 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestViewOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestViewOperations.java
@@ -140,6 +140,54 @@ public class TestViewOperations extends BaseOperationsTest 
{
     return resourceConfig;
   }
 
+  @Test
+  public void testRejectUnresolvedTypesBeforeDispatch() {
+    String[] columns = {
+      "{\"name\":\"c\",\"type\":\"future_type\"}",
+      
"{\"name\":\"c\",\"type\":{\"type\":\"list\",\"elementType\":\"future_type\"}}",
+      "{\"name\":\"c\",\"type\":{\"type\":\"external\",\"catalogString\":\" 
\"}}",
+      
"{\"name\":\"c\",\"type\":\"string\",\"defaultValue\":{\"type\":\"literal\",\"dataType\":\"future_type\",\"value\":null}}"
+    };
+    for (String column : columns) {
+      String create =
+          """
+          {"name":"v","columns":[%s],
+           "representations":[{"type":"sql","dialect":"trino","sql":"SELECT 
1"}]}
+          """
+              .formatted(column);
+      try (Response response =
+          target(viewPath(metalake, catalog, schema))
+              .request(MediaType.APPLICATION_JSON_TYPE)
+              .accept(VND_V1_JSON)
+              .post(Entity.json(create))) {
+        Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+        Assertions.assertTrue(
+            
response.readEntity(ErrorResponse.class).getMessage().contains("columns[0]"));
+      }
+
+      String replace =
+          """
+          {"updates":[{"@type":"setProperty","property":"k","value":"v"},
+            {"@type":"replaceView","columns":[%s],
+             "representations":[{"type":"sql","dialect":"trino","sql":"SELECT 
1"}]}]}
+          """
+              .formatted(column);
+      try (Response response =
+          target(viewPath(metalake, catalog, schema) + "/v")
+              .request(MediaType.APPLICATION_JSON_TYPE)
+              .accept(VND_V1_JSON)
+              .put(Entity.json(replace))) {
+        Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+        Assertions.assertTrue(
+            response
+                .readEntity(ErrorResponse.class)
+                .getMessage()
+                .contains("updates[1].columns[0]"));
+      }
+    }
+    Mockito.verifyNoInteractions(dispatcher);
+  }
+
   @Test
   public void testListViews() {
     NameIdentifier view1 = NameIdentifier.of(metalake, catalog, schema, 
"view1");

Reply via email to