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