This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 9b89234694 [Cherry-pick to branch-1.3] [#14608] fix(spark): convert
default SQL literals to typed values (#14609) (#14696)
9b89234694 is described below
commit 9b89234694e5f4a7f963c4bc3f8f4533e86192e1
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Nov 6 14:04:48 2026 +0800
[Cherry-pick to branch-1.3] [#14608] fix(spark): convert default SQL
literals to typed values (#14609) (#14696)
### What changes were proposed in this pull request?
Backport #14609 (`a93aa73eadef659a9550be276e721d461ffa6233`) to
`branch-1.3` and resolve the committed cherry-pick conflicts.
Keep typed default conversion in `SparkTableChangeConverter34`, which is
used by Spark 3.4 and 3.5. The shared converter retains Spark 3.3
compatibility through a lazy-schema overload. Move the default-value
regressions to the version-specific tests and add their required test
dependencies. Preserve the upstream JDBC binary encoding, timestamp
rendering, schema-loading and batch-validation behavior.
### Why are the changes needed?
Spark 3.3 does not provide the column-default update API used by the
main-branch fix. Copying it into the shared module prevents the 1.3
connector from compiling. Conflicted import/documentation context also
included main-only changes that do not belong in this backport.
### Does this PR introduce _any_ user-facing change?
Spark 3.4/3.5 default SQL literals are converted to typed values instead
of being stored as SQL text. Binary values survive REST serialization;
timestamp defaults follow the target column type and Spark session time
zone. Nonliteral expressions, unsupported DROP DEFAULT,
string-to-timestamp defaults and schema-changing batches combined with
schema-dependent defaults are rejected explicitly. Timestamp/date/string
defaults require a table-schema read, shared once per batch.
Spark 3.3 retains its existing supported table changes. The binary
encoding requires the updated server-side JDBC converters, and MySQL
zoned timestamp defaults require MySQL 8.0.19 or later, as documented in
#14609.
### How was this patch tested?
- All 141 targeted cases passed with zero failures, errors or skips: 11
shared Spark 3.3 cases, 36 Spark 3.4.3 cases, the same 36 default/schema
cases on the Spark 3.5.3 runtime, 10 DTO cases, 27 JDBC converter cases,
9 live MySQL timestamp cases and 12 PostgreSQL cases (including 3 live
binary-default cases).
- The Spark 3.5 run reused the 3.4 regression classes through a local
Gradle init script and verified the resolved Catalyst runtime was 3.5.3.
- Relevant Spotless checks, Spark 3.3 module compilation,
conflict-marker scan and `git diff --check` passed.
Remote CI for the conflict-resolution commit is pending. No deployed
Spark-to-server/database end-to-end run or full core suite was
performed.
---------
Co-authored-by: Qi Yu <[email protected]>
---
.../converter/JdbcColumnDefaultValueConverter.java | 46 ++-
.../TestJdbcColumnDefaultValueConverter.java | 74 ++++
catalogs/catalog-jdbc-mysql/build.gradle.kts | 1 +
.../mysql/operation/TestMysqlTableOperations.java | 98 ++++++
.../PostgreSqlColumnDefaultValueConverter.java | 8 +
.../TestPostgreSqlColumnDefaultValueConverter.java | 16 +
.../operation/TestPostgreSqlTableOperations.java | 49 +++
.../apache/gravitino/dto/util/DTOConverters.java | 7 +
.../gravitino/dto/util/TestDTOConverters.java | 19 +
docs/expression.md | 32 ++
.../spark/connector/SparkTableChangeConverter.java | 17 +
.../spark/connector/catalog/BaseCatalog.java | 31 +-
.../catalog/TestTransformTableChange.java | 14 +
spark-connector/v3.4/spark/build.gradle.kts | 1 +
.../connector/SparkTableChangeConverter34.java | 143 +++++++-
.../connector/TestSparkTableChangeConverter34.java | 385 ++++++++++++++++++++-
.../catalog/TestBaseCatalogDefaults34.java | 284 +++++++++++++++
17 files changed, 1195 insertions(+), 30 deletions(-)
diff --git
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/converter/JdbcColumnDefaultValueConverter.java
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/converter/JdbcColumnDefaultValueConverter.java
index ac3099a401..f4eecc197b 100644
---
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/converter/JdbcColumnDefaultValueConverter.java
+++
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/converter/JdbcColumnDefaultValueConverter.java
@@ -21,11 +21,12 @@ package org.apache.gravitino.catalog.jdbc.converter;
import static
org.apache.gravitino.catalog.jdbc.utils.JdbcConnectorUtils.escapeSqlLiteral;
import static org.apache.gravitino.rel.Column.DEFAULT_VALUE_NOT_SET;
+import com.google.common.io.BaseEncoding;
import java.math.BigDecimal;
-import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
+import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.rel.expressions.Expression;
import org.apache.gravitino.rel.expressions.FunctionExpression;
@@ -83,14 +84,35 @@ public class JdbcColumnDefaultValueConverter {
value = validateNumericLiteral(value);
}
return value;
+ } else if (type instanceof Types.BinaryType && literal.value()
instanceof byte[]) {
+ return binaryLiteral((byte[]) literal.value());
+ } else if (type instanceof Types.BinaryType
+ && literal.value() instanceof String
+ && ((String) literal.value()).startsWith("0x")) {
+ byte[] value;
+ try {
+ value =
+ BaseEncoding.base16()
+ .lowerCase()
+ .decode(((String)
literal.value()).substring(2).toLowerCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException(
+ "Invalid binary column default value: use 0x followed by
hexadecimal byte pairs", e);
+ }
+ return binaryLiteral(value);
} else if (type instanceof Types.TimestampType) {
- /**
- * @see LocalDateTime#toString() would return like
'yyyy-MM-ddTHH:mm:ss'
- */
- if (literal.value() instanceof String || literal.value() instanceof
LocalDateTime) {
- return quoteStringLiteral(literal.value().toString().replace("T", "
"));
+ // java.time omits seconds for whole-minute values, but MySQL requires
them.
+ String value =
+ literal
+ .value()
+ .toString()
+ .replace("T", " ")
+ .replaceFirst("^(\\d{4}-\\d{2}-\\d{2}
\\d{2}:\\d{2})(?=$|Z|[+-])", "$1:00");
+ if (((Types.TimestampType) type).hasTimeZone() && value.endsWith("Z"))
{
+ // MySQL accepts numeric offsets but rejects the equivalent ISO UTC
suffix.
+ value = value.substring(0, value.length() - 1) + "+00:00";
}
- return quoteStringLiteral(String.valueOf(literal.value()));
+ return quoteStringLiteral(value);
} else {
return quoteStringLiteral(String.valueOf(literal.value()));
}
@@ -119,6 +141,16 @@ public class JdbcColumnDefaultValueConverter {
return "'" + escapeSqlLiteral(value, '\'') + "'";
}
+ /**
+ * Renders binary bytes as a SQL literal. Dialects can override their binary
literal syntax.
+ *
+ * @param value the bytes to preserve
+ * @return a SQL expression containing the binary value
+ */
+ protected String binaryLiteral(byte[] value) {
+ return "X'" + BaseEncoding.base16().lowerCase().encode(value) + "'";
+ }
+
private static String validateNumericLiteral(String value) {
String trimmed = value.trim();
try {
diff --git
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/converter/TestJdbcColumnDefaultValueConverter.java
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/converter/TestJdbcColumnDefaultValueConverter.java
index 14070fafe2..a292ff0e6c 100644
---
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/converter/TestJdbcColumnDefaultValueConverter.java
+++
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/converter/TestJdbcColumnDefaultValueConverter.java
@@ -20,6 +20,9 @@ package org.apache.gravitino.catalog.jdbc.converter;
import static org.apache.gravitino.rel.Column.DEFAULT_VALUE_NOT_SET;
+import com.google.common.io.BaseEncoding;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.expressions.Expression;
import org.apache.gravitino.rel.expressions.literals.Literals;
@@ -33,6 +36,77 @@ public class TestJdbcColumnDefaultValueConverter {
private final JdbcColumnDefaultValueConverter converter = new
JdbcColumnDefaultValueConverter();
+ @ParameterizedTest
+ @ValueSource(strings = {"Z", "+08:00", "-07:00"})
+ void testTimestampDefaultsUseNumericOffsets(String offset) {
+ String value = "2026-11-04T12:34:56.123456" + offset;
+ String expected =
+ "'2026-11-04 12:34:56.123456" + (offset.equals("Z") ? "+00:00" :
offset) + "'";
+ Assertions.assertEquals(
+ expected,
+ converter.fromGravitino(
+ Literals.of(OffsetDateTime.parse(value),
Types.TimestampType.withTimeZone())));
+ Assertions.assertEquals(
+ expected, converter.fromGravitino(Literals.of(value,
Types.TimestampType.withTimeZone())));
+ Assertions.assertEquals(
+ "'2026-11-04 12:34:56.123456'",
+
converter.fromGravitino(Literals.timestampLiteral("2026-11-04T12:34:56.123456")));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"12:34:00", "00:00:00"})
+ void testWholeMinuteTimestampDefaultsIncludeSeconds(String time) {
+ String text = "2026-11-04T" + time;
+ for (String offset : new String[] {"Z", "+08:00", "-07:00"}) {
+ String expected = "'2026-11-04 " + time + (offset.equals("Z") ? "+00:00"
: offset) + "'";
+ OffsetDateTime value = OffsetDateTime.parse(text + offset);
+ Assertions.assertEquals(
+ expected,
+ converter.fromGravitino(Literals.of(value,
Types.TimestampType.withTimeZone())));
+ Assertions.assertEquals(
+ expected,
+ converter.fromGravitino(
+ Literals.of(value.toString(),
Types.TimestampType.withTimeZone())));
+ }
+ Assertions.assertEquals(
+ "'2026-11-04 " + time + "'",
+
converter.fromGravitino(Literals.timestampLiteral(LocalDateTime.parse(text))));
+ Assertions.assertEquals(
+ "'2026-11-04 " + time + "'",
+ converter.fromGravitino(
+ Literals.of(
+ LocalDateTime.parse(text).toString(),
Types.TimestampType.withoutTimeZone())));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"4142", "0080ff", ""})
+ void testBinaryDefaultsPreserveBytes(String hex) {
+ Assertions.assertEquals(
+ "X'" + hex + "'",
+ converter.fromGravitino(
+ Literals.of(BaseEncoding.base16().lowerCase().decode(hex),
Types.BinaryType.get())));
+ Assertions.assertEquals(
+ "X'" + hex + "'", converter.fromGravitino(Literals.of("0x" + hex,
Types.BinaryType.get())));
+ Assertions.assertEquals(
+ "'legacy'", converter.fromGravitino(Literals.of("legacy",
Types.BinaryType.get())));
+ Assertions.assertEquals("'0x4142'",
converter.fromGravitino(Literals.stringLiteral("0x4142")));
+ Assertions.assertEquals(
+ "X'00ff'", converter.fromGravitino(Literals.of("0x00FF",
Types.BinaryType.get())));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"0x0", "0xzz", "0x41'); DROP TABLE t; --"})
+ void testMalformedBinaryDefaultsAreRejected(String value) {
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> converter.fromGravitino(Literals.of(value,
Types.BinaryType.get())));
+ Assertions.assertEquals(
+ "Invalid binary column default value: use 0x followed by hexadecimal
byte pairs",
+ exception.getMessage());
+ Assertions.assertNotNull(exception.getCause());
+ }
+
@Test
public void testFromGravitino() {
Expression expression = DEFAULT_VALUE_NOT_SET;
diff --git a/catalogs/catalog-jdbc-mysql/build.gradle.kts
b/catalogs/catalog-jdbc-mysql/build.gradle.kts
index 712e0b5282..1671975993 100644
--- a/catalogs/catalog-jdbc-mysql/build.gradle.kts
+++ b/catalogs/catalog-jdbc-mysql/build.gradle.kts
@@ -51,6 +51,7 @@ dependencies {
testImplementation(project(":server-common"))
testImplementation(libs.awaitility)
+ testImplementation(libs.commons.dbcp2)
testImplementation(libs.junit.jupiter.api)
testImplementation(libs.junit.jupiter.params)
testImplementation(libs.mysql.driver)
diff --git
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperations.java
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperations.java
index 68d37eb09c..0f0ff3b6e0 100644
---
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperations.java
+++
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperations.java
@@ -21,9 +21,13 @@ package org.apache.gravitino.catalog.mysql.operation;
import static
org.apache.gravitino.catalog.mysql.MysqlTablePropertiesMetadata.MYSQL_AUTO_INCREMENT_OFFSET_KEY;
import static
org.apache.gravitino.catalog.mysql.MysqlTablePropertiesMetadata.MYSQL_ENGINE_KEY;
+import java.math.BigDecimal;
import java.sql.Connection;
+import java.sql.ResultSet;
import java.sql.Statement;
import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -32,11 +36,15 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.sql.DataSource;
+import org.apache.commons.dbcp2.BasicDataSource;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.catalog.jdbc.JdbcColumn;
import org.apache.gravitino.catalog.jdbc.JdbcTable;
import org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
+import
org.apache.gravitino.catalog.mysql.converter.MysqlColumnDefaultValueConverter;
+import org.apache.gravitino.catalog.mysql.converter.MysqlTypeConverter;
+import org.apache.gravitino.dto.util.DTOConverters;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.TableChange;
import org.apache.gravitino.rel.expressions.distributions.Distributions;
@@ -52,6 +60,7 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
@Tag("gravitino-docker-test")
@@ -59,6 +68,95 @@ public class TestMysqlTableOperations extends TestMysql {
private static final Type VARCHAR = Types.VarCharType.of(255);
private static final Type INT = Types.IntegerType.get();
+ @ParameterizedTest
+ @CsvSource({
+ "+00:00, 12:34:56.123456", "+08:00, 12:34:56.123456", "-07:00,
12:34:56.123456",
+ "+00:00, 12:34:00", "+08:00, 12:34:00", "-07:00, 12:34:00",
+ "+00:00, 00:00:00", "+08:00, 00:00:00", "-07:00, 00:00:00"
+ })
+ void testZonedTimestampDefaultsPreserveInstantOnCreateAndAlter(String
timeZone, String time)
+ throws Exception {
+ for (boolean zoned : new boolean[] {true, false}) {
+ String table = RandomNameUtils.genRandomName("timestamp_defaults");
+ OffsetDateTime value = OffsetDateTime.parse("2026-11-04T" + time + "Z");
+ try (BasicDataSource dataSource =
+ (BasicDataSource)
DataSourceUtils.createDataSource(getMySQLCatalogProperties())) {
+ dataSource.setConnectionInitSqls(List.of("SET time_zone = '" +
timeZone + "'"));
+ MysqlTableOperations operations = new MysqlTableOperations();
+ operations.initialize(
+ dataSource,
+ JDBC_EXCEPTION_CONVERTER,
+ new MysqlTypeConverter(),
+ new MysqlColumnDefaultValueConverter(),
+ Collections.emptyMap());
+ operations.create(
+ TEST_DB_NAME.toString(),
+ table,
+ new JdbcColumn[] {
+ JdbcColumn.builder()
+ .withName("ts")
+ .withType(
+ zoned
+ ? Types.TimestampType.withTimeZone(6)
+ : Types.TimestampType.withoutTimeZone(6))
+ .withNullable(true)
+ .withDefaultValue(
+ zoned
+ ? Literals.of(value,
Types.TimestampType.withTimeZone())
+ : Literals.timestampLiteral(value.toLocalDateTime()))
+ .build()
+ },
+ null,
+ Collections.emptyMap(),
+ null,
+ Distributions.NONE,
+ Indexes.EMPTY_INDEXES);
+ try (Connection connection = dataSource.getConnection();
+ Statement statement = connection.createStatement()) {
+ String qualified = "`" + TEST_DB_NAME + "`.`" + table + "`";
+ for (int iteration = 0; iteration < 2; iteration++) {
+ if (iteration == 1) {
+ statement.executeUpdate("DELETE FROM " + qualified);
+ value = value.plusDays(1);
+ operations.alterTable(
+ TEST_DB_NAME.toString(),
+ table,
+ TableChange.updateColumnDefaultValue(
+ new String[] {"ts"},
+ DTOConverters.toFunctionArg(
+ zoned
+ ? Literals.of(value,
Types.TimestampType.withTimeZone())
+ :
Literals.timestampLiteral(value.toLocalDateTime()))));
+ }
+ statement.executeUpdate("INSERT INTO " + qualified + " () VALUES
()");
+ try (ResultSet result =
+ statement.executeQuery(
+ "SELECT UNIX_TIMESTAMP(ts), @@session.time_zone,
DATE_FORMAT(ts, '%Y-%m-%d %H:%i:%s.%f') FROM "
+ + qualified)) {
+ Assertions.assertTrue(result.next());
+ if (zoned) {
+ Assertions.assertEquals(
+ 0,
+ BigDecimal.valueOf(value.toEpochSecond())
+ .add(BigDecimal.valueOf(value.getNano(), 9))
+ .compareTo(result.getBigDecimal(1)));
+ } else {
+ Assertions.assertEquals(
+ value
+ .toLocalDateTime()
+ .format(DateTimeFormatter.ofPattern("yyyy-MM-dd
HH:mm:ss.SSSSSS")),
+ result.getString(3));
+ }
+ Assertions.assertEquals(timeZone, result.getString(2));
+ }
+ }
+ } finally {
+ operations.drop(TEST_DB_NAME.toString(), table);
+ }
+ }
+ }
+ }
+
@Test
public void testOperationTable() {
String tableName = RandomStringUtils.randomAlphabetic(16) + "_op_table";
diff --git
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
index 7dc3dc5398..e475489895 100644
---
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
+++
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
@@ -22,6 +22,7 @@ import static
org.apache.gravitino.catalog.jdbc.converter.JdbcTypeConverter.VARC
import static org.apache.gravitino.rel.Column.DEFAULT_VALUE_NOT_SET;
import static
org.apache.gravitino.rel.Column.DEFAULT_VALUE_OF_CURRENT_TIMESTAMP;
+import com.google.common.io.BaseEncoding;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -94,6 +95,13 @@ public class PostgreSqlColumnDefaultValueConverter extends
JdbcColumnDefaultValu
return "'" + value.replace("'", "''") + "'";
}
+ /** {@inheritDoc} */
+ @Override
+ protected String binaryLiteral(byte[] value) {
+ // PostgreSQL's X'...' literal is a bit string, so decode hex explicitly
for bytea.
+ return "decode('" + BaseEncoding.base16().lowerCase().encode(value) + "',
'hex')";
+ }
+
private Expression parseLiteral(JdbcTypeConverter.JdbcTypeBean type, String
columnDefaultValue) {
Matcher matcher = VALUE_WITH_TYPE.matcher(columnDefaultValue);
if (matcher.find()) {
diff --git
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
index dcf29eab48..f23c6ad07d 100644
---
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
+++
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
@@ -18,16 +18,32 @@
*/
package org.apache.gravitino.catalog.postgresql.converter;
+import com.google.common.io.BaseEncoding;
import org.apache.gravitino.catalog.jdbc.converter.JdbcTypeConverter;
import org.apache.gravitino.rel.expressions.literals.Literals;
+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.ValueSource;
public class TestPostgreSqlColumnDefaultValueConverter {
private final PostgreSqlColumnDefaultValueConverter converter =
new PostgreSqlColumnDefaultValueConverter();
+ @ParameterizedTest
+ @ValueSource(strings = {"4142", "0080ff", ""})
+ void testBinaryDefaultsUseByteaSyntax(String hex) {
+ Assertions.assertEquals(
+ "decode('" + hex + "', 'hex')",
+ converter.fromGravitino(
+ Literals.of(BaseEncoding.base16().lowerCase().decode(hex),
Types.BinaryType.get())));
+ Assertions.assertEquals(
+ "decode('" + hex + "', 'hex')",
+ converter.fromGravitino(Literals.of("0x" + hex,
Types.BinaryType.get())));
+ }
+
@Test
void testNulStringDefaultIsRejectedBeforeDdl() {
IllegalArgumentException error =
diff --git
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
index 74374f9193..58c9ec2eff 100644
---
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
+++
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.catalog.postgresql.operation;
import com.google.common.collect.Maps;
+import com.google.common.io.BaseEncoding;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -40,6 +41,7 @@ import
org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
import org.apache.gravitino.catalog.jdbc.utils.JdbcConnectorUtils;
import
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlColumnDefaultValueConverter;
import
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlTypeConverter;
+import org.apache.gravitino.dto.util.DTOConverters;
import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.GravitinoRuntimeException;
import org.apache.gravitino.exceptions.NoSuchSchemaException;
@@ -69,6 +71,53 @@ public class TestPostgreSqlTableOperations extends
TestPostgreSql {
private static Type VARCHAR = Types.VarCharType.of(255);
private static Type INT = Types.IntegerType.get();
+ @ParameterizedTest
+ @ValueSource(strings = {"4142", "0080ff", ""})
+ void testBinaryDefaultsPreserveBytesOnCreateAndAlter(String hex) throws
SQLException {
+ String table = RandomNameUtils.genRandomName("binary_defaults");
+ byte[] bytes = BaseEncoding.base16().lowerCase().decode(hex);
+ TABLE_OPERATIONS.create(
+ TEST_DB_NAME,
+ table,
+ new JdbcColumn[] {
+ JdbcColumn.builder()
+ .withName("payload")
+ .withType(Types.BinaryType.get())
+ .withNullable(true)
+ .withDefaultValue(Literals.of(bytes, Types.BinaryType.get()))
+ .build()
+ },
+ null,
+ Collections.emptyMap(),
+ null,
+ Distributions.NONE,
+ Indexes.EMPTY_INDEXES);
+ try (Connection connection = DATA_SOURCE.getConnection();
+ Statement statement = connection.createStatement()) {
+ String qualified = TEST_DB_NAME + "." + table;
+ statement.executeUpdate("INSERT INTO " + qualified + " DEFAULT VALUES");
+ try (ResultSet result = statement.executeQuery("SELECT payload FROM " +
qualified)) {
+ Assertions.assertTrue(result.next());
+ Assertions.assertArrayEquals(bytes, result.getBytes(1));
+ }
+ statement.executeUpdate("DELETE FROM " + qualified);
+ byte[] altered = BaseEncoding.base16().lowerCase().decode(hex + "ff");
+ TABLE_OPERATIONS.alterTable(
+ TEST_DB_NAME,
+ table,
+ TableChange.updateColumnDefaultValue(
+ new String[] {"payload"},
+ DTOConverters.toFunctionArg(Literals.of(altered,
Types.BinaryType.get()))));
+ statement.executeUpdate("INSERT INTO " + qualified + " DEFAULT VALUES");
+ try (ResultSet result = statement.executeQuery("SELECT payload FROM " +
qualified)) {
+ Assertions.assertTrue(result.next());
+ Assertions.assertArrayEquals(altered, result.getBytes(1));
+ }
+ } finally {
+ TABLE_OPERATIONS.drop(TEST_DB_NAME, table);
+ }
+ }
+
@ParameterizedTest
@ValueSource(strings = {"quoted\";CREATE TABLE injected_marker (id int);--"})
void testQuotedTableNamesStayIdentifiersDuringAlter(String name) {
diff --git
a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
index e9ee2226dd..eed5e2c57a 100644
--- a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
+++ b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.dto.util;
import static
org.apache.gravitino.rel.expressions.transforms.Transforms.NAME_OF_IDENTITY;
+import com.google.common.io.BaseEncoding;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
@@ -692,6 +693,12 @@ public class DTOConverters {
}
Object value = ((Literal) expression).value();
+ // Binary literals use an explicit hexadecimal representation on the
string-valued wire API.
+ // byte[].toString() is an object identity and cannot preserve the
literal's bytes.
+ if (((Literal) expression).dataType() instanceof Types.BinaryType
+ && value instanceof byte[]) {
+ value = "0x" + BaseEncoding.base16().lowerCase().encode((byte[])
value);
+ }
return LiteralDTO.builder()
.withValue(value == null ? null : value.toString())
.withDataType(((Literal) expression).dataType())
diff --git
a/common/src/test/java/org/apache/gravitino/dto/util/TestDTOConverters.java
b/common/src/test/java/org/apache/gravitino/dto/util/TestDTOConverters.java
index 46a2ffa11b..f13ce8d7ad 100644
--- a/common/src/test/java/org/apache/gravitino/dto/util/TestDTOConverters.java
+++ b/common/src/test/java/org/apache/gravitino/dto/util/TestDTOConverters.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.dto.util;
+import com.google.common.io.BaseEncoding;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
@@ -41,9 +42,27 @@ import org.apache.gravitino.rel.partitions.RangePartition;
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.ValueSource;
public class TestDTOConverters {
+ @ParameterizedTest
+ @ValueSource(strings = {"4142", "0080ff", ""})
+ void testBinaryLiteralBytesUseHex(String hex) {
+ LiteralDTO dto =
+ (LiteralDTO)
+ DTOConverters.toFunctionArg(
+ Literals.of(BaseEncoding.base16().lowerCase().decode(hex),
Types.BinaryType.get()));
+ Assertions.assertEquals(Types.BinaryType.get(), dto.dataType());
+ Assertions.assertEquals("0x" + hex, dto.value());
+ Assertions.assertEquals("0x" + hex, ((Literal<?>)
DTOConverters.fromFunctionArg(dto)).value());
+ Assertions.assertEquals(
+ "legacy",
+ ((LiteralDTO) DTOConverters.toFunctionArg(Literals.of("legacy",
Types.BinaryType.get())))
+ .value());
+ }
+
@Test
void testIdentityPartitionDTOConvert() {
diff --git a/docs/expression.md b/docs/expression.md
index 8962497020..790a1a053e 100644
--- a/docs/expression.md
+++ b/docs/expression.md
@@ -46,6 +46,38 @@ NamedReference field = NamedReference.field("student");
## Literal
Literal is a constant value.
+
+Binary literals created from a Java `byte[]` are encoded in REST literal
values as `0x` followed by
+hexadecimal byte pairs, for example `"dataType": "binary", "value": "0x4142"`
for the bytes `AB`.
+`"0x"` represents an empty binary value. JDBC default converters recognize
this encoding and render
+the bytes using their dialect's binary syntax; provider restrictions on binary
defaults still apply.
+Unprefixed binary string values retain their previous provider-specific
interpretation. A binary
+string starting with `0x` must contain valid hexadecimal pairs; it is treated
as encoded bytes.
+Sending encoded binary defaults requires the server-side JDBC default
converters described here;
+older servers do not decode this representation.
+
+Spark timestamp defaults are converted to the target column type in the Spark
session time zone.
+An LTZ literal assigned to an NTZ column keeps the local fields produced by
Spark's timestamp cast;
+an NTZ literal assigned to an LTZ column is interpreted in the Spark session
zone before it is sent
+as an instant. DATE defaults assigned to timestamp columns become midnight in
the Spark session
+zone for LTZ targets, or local midnight for NTZ targets. String defaults for
timestamp columns are
+explicitly rejected in Spark 3.4 and 3.5; use a `TIMESTAMP_LTZ` or
`TIMESTAMP_NTZ` literal instead
+of relying on implicit database conversion. Date and string defaults for
non-timestamp columns
+retain their literal values and types.
+
+Timestamp, date, and string defaults load the current table schema once
through Gravitino and
+require table load permission, including date/string defaults for
non-timestamp columns. Other
+literal defaults do not add a schema read. Batches combining any of these
schema-dependent defaults
+with column additions, deletions, renames, or type changes are rejected before
writing because they
+require sequential schema evolution.
+
+JDBC timestamp defaults include seconds even for whole-minute and midnight
values.
+Zoned timestamp defaults use numeric UTC offsets in JDBC SQL. MySQL requires
8.0.19 or later
+for [datetime literals with
offsets](https://dev.mysql.com/doc/refman/8.0/en/datetime.html);
+MySQL 5.7 and older 8.0 releases cannot use these zoned defaults. Spark `DROP
DEFAULT` is not
+supported by the current Gravitino update request and is rejected explicitly;
`SET DEFAULT NULL`
+and `SET DEFAULT ''` remain distinct supported literal values.
+
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>
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/SparkTableChangeConverter.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/SparkTableChangeConverter.java
index 1db82c2d8c..06fef33be3 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/SparkTableChangeConverter.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/SparkTableChangeConverter.java
@@ -19,6 +19,8 @@
package org.apache.gravitino.spark.connector;
import com.google.common.base.Preconditions;
+import java.util.function.Supplier;
+import org.apache.gravitino.rel.Column;
import org.apache.spark.sql.connector.catalog.TableChange;
public class SparkTableChangeConverter {
@@ -93,6 +95,21 @@ public class SparkTableChangeConverter {
}
}
+ /**
+ * Converts a table change with a lazy supplier for target column
information.
+ *
+ * <p>Version-specific converters may use the schema for defaults supported
by that Spark version.
+ * The Spark 3.3 converter does not load the schema.
+ *
+ * @param change the Spark table change
+ * @param columns supplies the current table columns only when required by
the change
+ * @return the converted Gravitino table change
+ */
+ public org.apache.gravitino.rel.TableChange toGravitinoTableChange(
+ TableChange change, Supplier<Column[]> columns) {
+ return toGravitinoTableChange(change);
+ }
+
private org.apache.gravitino.rel.TableChange.ColumnPosition
transformColumnPosition(
TableChange.ColumnPosition columnPosition) {
if (null == columnPosition) {
diff --git
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
index 4492697486..90e2ff8ae1 100644
---
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
+++
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/catalog/BaseCatalog.java
@@ -20,11 +20,13 @@
package org.apache.gravitino.spark.connector.catalog;
import com.google.common.base.Preconditions;
+import com.google.common.base.Suppliers;
import com.google.common.collect.Sets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
+import java.util.function.Supplier;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.Catalog;
import org.apache.gravitino.NameIdentifier;
@@ -41,6 +43,7 @@ import org.apache.gravitino.function.Function;
import org.apache.gravitino.function.FunctionDefinition;
import org.apache.gravitino.function.FunctionImpl;
import org.apache.gravitino.function.JavaImpl;
+import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.View;
import org.apache.gravitino.spark.connector.ConnectorConstants;
import org.apache.gravitino.spark.connector.PropertiesConverter;
@@ -274,11 +277,31 @@ public abstract class BaseCatalog implements
TableCatalog, SupportsNamespaces, F
@Override
public Table alterTable(Identifier ident, TableChange... changes) throws
NoSuchTableException {
- org.apache.gravitino.rel.TableChange[] gravitinoTableChanges =
- Arrays.stream(changes)
- .map(sparkTableChangeConverter::toGravitinoTableChange)
- .toArray(org.apache.gravitino.rel.TableChange[]::new);
try {
+ Supplier<Column[]> columns =
+ Suppliers.memoize(
+ () -> {
+ // A schema-changing batch needs sequential schema
evolution before coercion.
+ // Reject it before any write rather than resolve against
stale column types.
+ Preconditions.checkArgument(
+ Arrays.stream(changes)
+ .noneMatch(
+ change ->
+ change instanceof TableChange.AddColumn
+ || change instanceof
TableChange.DeleteColumn
+ || change instanceof
TableChange.RenameColumn
+ || change instanceof
TableChange.UpdateColumnType),
+ "Timestamp, date, and string defaults cannot be
combined with column schema changes");
+ return gravitinoCatalogClient
+ .asTableCatalog()
+ .loadTable(NameIdentifier.of(getDatabase(ident),
ident.name()))
+ .columns();
+ })
+ ::get;
+ org.apache.gravitino.rel.TableChange[] gravitinoTableChanges =
+ Arrays.stream(changes)
+ .map(change ->
sparkTableChangeConverter.toGravitinoTableChange(change, columns))
+ .toArray(org.apache.gravitino.rel.TableChange[]::new);
invalidateTable(ident);
org.apache.gravitino.rel.Table gravitinoTable =
gravitinoCatalogClient
diff --git
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestTransformTableChange.java
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestTransformTableChange.java
index 523deb9021..1dc0e93970 100644
---
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestTransformTableChange.java
+++
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/catalog/TestTransformTableChange.java
@@ -32,6 +32,20 @@ public class TestTransformTableChange {
private SparkTableChangeConverter sparkTableChangeConverter =
new SparkTableChangeConverter(new SparkTypeConverter());
+ @Test
+ void testTransformWithoutLoadingTargetSchema() {
+ org.apache.gravitino.rel.TableChange change =
+ sparkTableChangeConverter.toGravitinoTableChange(
+ TableChange.setProperty("key", "value"),
+ () -> {
+ throw new AssertionError("Spark 3.3 table changes must not load
the schema");
+ });
+ org.apache.gravitino.rel.TableChange.SetProperty property =
+
Assertions.assertInstanceOf(org.apache.gravitino.rel.TableChange.SetProperty.class,
change);
+ Assertions.assertEquals("key", property.getProperty());
+ Assertions.assertEquals("value", property.getValue());
+ }
+
@Test
void testTransformSetProperty() {
TableChange sparkSetProperty = TableChange.setProperty("key", "value");
diff --git a/spark-connector/v3.4/spark/build.gradle.kts
b/spark-connector/v3.4/spark/build.gradle.kts
index d4da6062fc..bd3dcf840f 100644
--- a/spark-connector/v3.4/spark/build.gradle.kts
+++ b/spark-connector/v3.4/spark/build.gradle.kts
@@ -143,6 +143,7 @@ dependencies {
}
testImplementation(libs.junit.jupiter.api)
testImplementation(libs.junit.jupiter.params)
+ testImplementation(libs.mockito.core)
testImplementation(libs.mysql.driver)
testImplementation(libs.postgresql.driver)
testImplementation(libs.testcontainers)
diff --git
a/spark-connector/v3.4/spark/src/main/java/org/apache/gravitino/spark/connector/SparkTableChangeConverter34.java
b/spark-connector/v3.4/spark/src/main/java/org/apache/gravitino/spark/connector/SparkTableChangeConverter34.java
index 2aa8b1a807..fa95206864 100644
---
a/spark-connector/v3.4/spark/src/main/java/org/apache/gravitino/spark/connector/SparkTableChangeConverter34.java
+++
b/spark-connector/v3.4/spark/src/main/java/org/apache/gravitino/spark/connector/SparkTableChangeConverter34.java
@@ -18,24 +18,157 @@
*/
package org.apache.gravitino.spark.connector;
+import com.google.common.base.Preconditions;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.function.Supplier;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.expressions.Expression;
import org.apache.gravitino.rel.expressions.literals.Literals;
+import org.apache.gravitino.rel.types.Type;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.spark.connector.utils.SparkPartitionUtils;
+import org.apache.spark.sql.catalyst.expressions.GenericInternalRow;
+import org.apache.spark.sql.catalyst.expressions.Literal;
+import org.apache.spark.sql.catalyst.expressions.UnaryMinus;
+import org.apache.spark.sql.catalyst.expressions.UnaryPositive;
+import org.apache.spark.sql.catalyst.parser.CatalystSqlParser$;
+import org.apache.spark.sql.catalyst.util.DateTimeUtils;
import org.apache.spark.sql.connector.catalog.TableChange;
+import org.apache.spark.sql.internal.SQLConf;
+import org.apache.spark.sql.types.BinaryType;
+import org.apache.spark.sql.types.DateType;
+import org.apache.spark.sql.types.StringType;
+import org.apache.spark.sql.types.TimestampNTZType;
+import org.apache.spark.sql.types.TimestampType;
public class SparkTableChangeConverter34 extends SparkTableChangeConverter {
public SparkTableChangeConverter34(SparkTypeConverter sparkTypeConverter) {
super(sparkTypeConverter);
}
+ /** {@inheritDoc} */
@Override
public org.apache.gravitino.rel.TableChange
toGravitinoTableChange(TableChange change) {
+ return toGravitinoTableChange(
+ change,
+ () -> {
+ throw new IllegalArgumentException(
+ "Timestamp, date, and string defaults require the target column
schema");
+ });
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public org.apache.gravitino.rel.TableChange toGravitinoTableChange(
+ TableChange change, Supplier<Column[]> columns) {
if (change instanceof TableChange.UpdateColumnDefaultValue) {
- TableChange.UpdateColumnDefaultValue updateColumnDefaultValue =
+ TableChange.UpdateColumnDefaultValue defaultValue =
(TableChange.UpdateColumnDefaultValue) change;
return org.apache.gravitino.rel.TableChange.updateColumnDefaultValue(
- updateColumnDefaultValue.fieldNames(),
- Literals.stringLiteral(updateColumnDefaultValue.newDefaultValue()));
- } else {
- return super.toGravitinoTableChange(change);
+ defaultValue.fieldNames(),
+ convertDefaultValue(defaultValue.newDefaultValue(),
defaultValue.fieldNames(), columns));
+ }
+ return super.toGravitinoTableChange(change);
+ }
+
+ private Expression convertDefaultValue(
+ String sql, String[] fieldNames, Supplier<Column[]> columns) {
+ // Spark uses an empty SQL string for DROP DEFAULT; a string literal
containing no characters
+ // is represented by two quote characters instead.
+ if (sql.isEmpty()) {
+ throw new UnsupportedOperationException("DROP DEFAULT is not supported
by Gravitino");
+ }
+ org.apache.spark.sql.catalyst.expressions.Expression parsed;
+ Object value;
+ try {
+ parsed = CatalystSqlParser$.MODULE$.parseExpression(sql);
+ boolean literal =
+ parsed instanceof Literal
+ || (parsed instanceof UnaryMinus && ((UnaryMinus)
parsed).child() instanceof Literal)
+ || (parsed instanceof UnaryPositive
+ && ((UnaryPositive) parsed).child() instanceof Literal);
+ Preconditions.checkArgument(
+ literal && parsed.resolved() && parsed.foldable(),
+ "Only literal column defaults are supported: %s",
+ sql);
+ value = parsed.eval(null);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Unsupported column default SQL: " +
sql, e);
+ }
+ boolean timestampLiteral =
+ parsed.dataType() instanceof TimestampType || parsed.dataType()
instanceof TimestampNTZType;
+ if (timestampLiteral
+ || parsed.dataType() instanceof DateType
+ || parsed.dataType() instanceof StringType) {
+ Type target = columnType(columns.get(), fieldNames);
+ Preconditions.checkArgument(
+ !timestampLiteral || target instanceof Types.TimestampType,
+ "Timestamp defaults require a timestamp target column: %s",
+ String.join(".", fieldNames));
+ if (target instanceof Types.TimestampType) {
+ Preconditions.checkArgument(
+ !(parsed.dataType() instanceof StringType),
+ "String defaults for timestamp column %s are unsupported; use a
TIMESTAMP_LTZ or TIMESTAMP_NTZ literal",
+ String.join(".", fieldNames));
+ // Coerce in Spark's session zone before JDBC sees the value. A
database connection's
+ // zone must not decide how a date or local timestamp is assigned to
an instant.
+ ZoneId zone =
DateTimeUtils.getZoneId(SQLConf.get().sessionLocalTimeZone());
+ boolean targetZoned = ((Types.TimestampType) target).hasTimeZone();
+ if (parsed.dataType() instanceof DateType) {
+ return targetZoned
+ ? Literals.of(
+
DateTimeUtils.microsToInstant(DateTimeUtils.daysToMicros((int) value, zone))
+ .atOffset(ZoneOffset.UTC),
+ Types.TimestampType.withTimeZone())
+ : Literals.timestampLiteral(LocalDate.ofEpochDay((int)
value).atStartOfDay());
+ }
+ boolean sourceZoned = parsed.dataType() instanceof TimestampType;
+ if (targetZoned) {
+ return Literals.of(
+ (sourceZoned
+ ? DateTimeUtils.microsToInstant((long) value)
+ : DateTimeUtils.microsToLocalDateTime((long)
value).atZone(zone).toInstant())
+ .atOffset(ZoneOffset.UTC),
+ Types.TimestampType.withTimeZone());
+ }
+ return Literals.timestampLiteral(
+ sourceZoned
+ ? DateTimeUtils.microsToInstant((long)
value).atZone(zone).toLocalDateTime()
+ : DateTimeUtils.microsToLocalDateTime((long) value));
+ }
+ }
+ if (parsed.dataType() instanceof BinaryType) {
+ return Literals.of((byte[]) value, Types.BinaryType.get());
+ }
+ return SparkPartitionUtils.toGravitinoLiteral(
+ new GenericInternalRow(new Object[] {value}), 0, parsed.dataType());
+ }
+
+ private Type columnType(Column[] columns, String[] fieldNames) {
+ Type type =
+ Arrays.stream(columns)
+ .filter(column -> column.name().equals(fieldNames[0]))
+ .findFirst()
+ .orElseThrow(
+ () -> new IllegalArgumentException("Unknown default column: "
+ fieldNames[0]))
+ .dataType();
+ for (int i = 1; i < fieldNames.length; i++) {
+ Preconditions.checkArgument(
+ type instanceof Types.StructType,
+ "Unsupported nested default column: %s",
+ String.join(".", fieldNames));
+ String fieldName = fieldNames[i];
+ type =
+ Arrays.stream(((Types.StructType) type).fields())
+ .filter(field -> field.name().equals(fieldName))
+ .findFirst()
+ .orElseThrow(
+ () -> new IllegalArgumentException("Unknown default column:
" + fieldName))
+ .type();
}
+ return type;
}
}
diff --git
a/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/TestSparkTableChangeConverter34.java
b/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/TestSparkTableChangeConverter34.java
index 67ea6c7bbd..e8b77f24b7 100644
---
a/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/TestSparkTableChangeConverter34.java
+++
b/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/TestSparkTableChangeConverter34.java
@@ -16,39 +16,396 @@
* specific language governing permissions and limitations
* under the License.
*/
+
package org.apache.gravitino.spark.connector;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.HexFormat;
+import java.util.Locale;
+import
org.apache.gravitino.catalog.jdbc.converter.JdbcColumnDefaultValueConverter;
+import org.apache.gravitino.dto.rel.expressions.LiteralDTO;
+import
org.apache.gravitino.dto.requests.TableUpdateRequest.UpdateTableColumnDefaultValueRequest;
+import org.apache.gravitino.dto.util.DTOConverters;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.expressions.Expression;
+import org.apache.gravitino.rel.expressions.literals.Literal;
import org.apache.gravitino.rel.expressions.literals.Literals;
+import org.apache.gravitino.rel.types.Decimal;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.spark.sql.catalyst.expressions.Cast;
+import org.apache.spark.sql.catalyst.parser.CatalystSqlParser$;
+import org.apache.spark.sql.catalyst.util.DateTimeUtils;
import org.apache.spark.sql.connector.catalog.TableChange;
+import org.apache.spark.sql.internal.SQLConf;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
public class TestSparkTableChangeConverter34 {
-
private SparkTableChangeConverter sparkTableChangeConverter =
new SparkTableChangeConverter34(new SparkTypeConverter34());
@Test
- void testUpdateColumnDefaultValue() {
+ void testTransformSetProperty() {
+ TableChange sparkSetProperty = TableChange.setProperty("key", "value");
+ org.apache.gravitino.rel.TableChange tableChange =
+ sparkTableChangeConverter.toGravitinoTableChange(sparkSetProperty);
+ Assertions.assertTrue(tableChange instanceof
org.apache.gravitino.rel.TableChange.SetProperty);
+ org.apache.gravitino.rel.TableChange.SetProperty gravitinoSetProperty =
+ (org.apache.gravitino.rel.TableChange.SetProperty) tableChange;
+ Assertions.assertEquals("key", gravitinoSetProperty.getProperty());
+ Assertions.assertEquals("value", gravitinoSetProperty.getValue());
+ }
+
+ @Test
+ void testTransformUpdateColumnDefaultValue() {
String[] fieldNames = new String[] {"col"};
- String defaultValue = "col_default_value";
- TableChange.UpdateColumnDefaultValue sparkUpdateColumnDefaultValue =
+ String defaultValue = "'col_default_value'";
+ TableChange.UpdateColumnDefaultValue sparkChange =
(TableChange.UpdateColumnDefaultValue)
TableChange.updateColumnDefaultValue(fieldNames, defaultValue);
org.apache.gravitino.rel.TableChange gravitinoChange =
-
sparkTableChangeConverter.toGravitinoTableChange(sparkUpdateColumnDefaultValue);
+ sparkTableChangeConverter.toGravitinoTableChange(
+ sparkChange, () -> new Column[] {Column.of("col",
Types.StringType.get(), null)});
+
+ Assertions.assertInstanceOf(
+ org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue.class,
gravitinoChange);
+ org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue converted =
+ (org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue)
gravitinoChange;
+ Assertions.assertArrayEquals(sparkChange.fieldNames(),
converted.fieldName());
+ Assertions.assertEquals(
+ Literals.stringLiteral("col_default_value"),
converted.getNewDefaultValue());
+ }
+
+ @Test
+ void testDefaultSqlIsConvertedToTypedLiterals() {
+ assertDefault("42", Literals.integerLiteral(42));
+ assertDefault("-42", Literals.integerLiteral(-42));
+ assertDefault("12.34", Literals.decimalLiteral(Decimal.of("12.34", 4, 2)));
+ assertDefault("true", Literals.booleanLiteral(true));
+ assertDefault("NULL", Literals.NULL);
+ assertDefault("''", Literals.stringLiteral(""));
+ assertDefault("DATE '2026-11-04'", Literals.dateLiteral("2026-11-04"));
+ UnsupportedOperationException exception =
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () ->
+ sparkTableChangeConverter.toGravitinoTableChange(
+ TableChange.updateColumnDefaultValue(new String[] {"col"},
"")));
+ Assertions.assertEquals("DROP DEFAULT is not supported by Gravitino",
exception.getMessage());
+ for (String expression :
+ new String[] {
+ "1 + 1",
+ "current_timestamp()",
+ "upper('abc')",
+ "not valid SQL!",
+ "column_name",
+ "+(1 + 1)",
+ "+'abc'",
+ "+true"
+ }) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ sparkTableChangeConverter.toGravitinoTableChange(
+ TableChange.updateColumnDefaultValue(new String[] {"col"},
expression)));
+ }
+ }
+
+ @Test
+ void testExplicitPositiveLiteralDefaults() {
+ assertDefault("+42", Literals.integerLiteral(42));
+ assertDefault("+42L", Literals.longLiteral(42L));
+ assertDefault("+12.34", Literals.decimalLiteral(Decimal.of("12.34", 4,
2)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"UTC", "Asia/Shanghai", "America/Los_Angeles"})
+ void testTimestampDefaultsPreserveInstant(String timeZone) {
+ withSessionConfig(
+ timeZone,
+ "TIMESTAMP_LTZ",
+ () -> {
+ for (String text :
+ new String[] {
+ "2026-11-04T12:34:56.123456",
+ "1969-12-31T23:59:59.999999",
+ "2026-11-04T12:34:00",
+ "2026-11-04T00:00:00"
+ }) {
+ Expression expected =
+ Literals.of(
+ LocalDateTime.parse(text)
+ .atZone(ZoneId.of(timeZone))
+ .toInstant()
+ .atOffset(ZoneOffset.UTC),
+ Types.TimestampType.withTimeZone());
+ Expression converted = assertDefault("TIMESTAMP '" + text + "'",
expected);
+ String expectedSql =
+ "'"
+ + ((Literal<?>) expected)
+ .value()
+ .toString()
+ .replace("T", " ")
+ .replace("Z", "+00:00")
+ .replaceFirst("^(\\d{4}-\\d{2}-\\d{2}
\\d{2}:\\d{2})(?=[+-])", "$1:00")
+ + "'";
+ Assertions.assertEquals(
+ expectedSql,
+ new JdbcColumnDefaultValueConverter()
+ .fromGravitino(DTOConverters.toFunctionArg(converted)));
+ assertDefault("TIMESTAMP_LTZ '" + text + "'", expected);
+ }
+ assertDefault(
+ "TIMESTAMP '2026-11-04 12:34:56.123456+02:00'",
+ Literals.of(
+
LocalDateTime.parse("2026-11-04T10:34:56.123456").atOffset(ZoneOffset.UTC),
+ Types.TimestampType.withTimeZone()));
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"UTC", "Asia/Shanghai", "America/Los_Angeles"})
+ void testTimestampNtzDefaultsPreserveLocalTime(String timeZone) {
+ withSessionConfig(
+ timeZone,
+ "TIMESTAMP_NTZ",
+ () -> {
+ for (String text :
+ new String[] {
+ "2026-11-04T12:34:56.123456",
+ "1969-12-31T23:59:59.999999",
+ "2026-11-04T12:34:00",
+ "2026-11-04T00:00:00"
+ }) {
+ Expression expected = Literals.timestampLiteral(text);
+ assertDefault("TIMESTAMP_NTZ '" + text + "'", expected);
+ assertDefault("TIMESTAMP '" + text + "'", expected);
+ }
+ });
+ }
- Assertions.assertTrue(
- gravitinoChange instanceof
org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue);
- org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue
- gravitinoUpdateColumnDefaultValue =
- (org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue)
gravitinoChange;
+ @ParameterizedTest
+ @ValueSource(strings = {"UTC", "Asia/Shanghai", "America/Los_Angeles"})
+ void testTimestampDefaultsAreCoercedToTargetColumn(String timeZone) {
+ for (String timestampType : new String[] {"TIMESTAMP_LTZ",
"TIMESTAMP_NTZ"}) {
+ withSessionConfig(
+ timeZone,
+ timestampType,
+ () -> {
+ String text = "2026-11-04T12:34:56.123456";
+ assertDefault("TIMESTAMP_LTZ '" + text + "'",
Literals.timestampLiteral(text));
+ try {
+ Cast cast =
+ (Cast)
+ CatalystSqlParser$.MODULE$.parseExpression(
+ "CAST(TIMESTAMP_LTZ '" + text + "' AS
TIMESTAMP_NTZ)");
+ Assertions.assertEquals(
+ LocalDateTime.parse(text),
+ DateTimeUtils.microsToLocalDateTime(
+ (long) ((Cast) cast.withTimeZone(timeZone)).eval(null)));
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ assertDefault(
+ "TIMESTAMP_NTZ '" + text + "'",
+ Literals.of(
+ LocalDateTime.parse(text)
+ .atZone(ZoneId.of(timeZone))
+ .toInstant()
+ .atOffset(ZoneOffset.UTC),
+ Types.TimestampType.withTimeZone()));
+ assertDefault(
+ "TIMESTAMP_LTZ '2026-11-04 12:34:56.123456+02:00'",
+ Literals.timestampLiteral(
+ LocalDateTime.parse(text)
+ .atOffset(ZoneOffset.ofHours(2))
+ .toInstant()
+ .atZone(ZoneId.of(timeZone))
+ .toLocalDateTime()));
+ });
+ }
+ }
- Assertions.assertArrayEquals(
- sparkUpdateColumnDefaultValue.fieldNames(),
gravitinoUpdateColumnDefaultValue.fieldName());
+ @ParameterizedTest
+ @ValueSource(strings = {"UTC", "Asia/Shanghai", "America/Los_Angeles",
"Pacific/Apia"})
+ void testDateDefaultsAreCoercedToTimestampTarget(String timeZone) {
+ for (String timestampType : new String[] {"TIMESTAMP_LTZ",
"TIMESTAMP_NTZ"}) {
+ withSessionConfig(
+ timeZone,
+ timestampType,
+ () -> {
+ for (String date : new String[] {"2026-11-04", "1969-12-31",
"2011-12-30"}) {
+ String sql = "DATE '" + date + "'";
+ for (String target : new String[] {"TIMESTAMP_LTZ",
"TIMESTAMP_NTZ"}) {
+ try {
+ Cast cast =
+ (Cast)
+ CatalystSqlParser$.MODULE$.parseExpression(
+ "CAST(" + sql + " AS " + target + ")");
+ long micros = (long) ((Cast)
cast.withTimeZone(timeZone)).eval(null);
+ Expression expected =
+ target.equals("TIMESTAMP_LTZ")
+ ? Literals.of(
+
DateTimeUtils.microsToInstant(micros).atOffset(ZoneOffset.UTC),
+ Types.TimestampType.withTimeZone())
+ :
Literals.timestampLiteral(DateTimeUtils.microsToLocalDateTime(micros));
+ Expression converted = assertDefault(sql, expected);
+ UpdateTableColumnDefaultValueRequest request =
+ new UpdateTableColumnDefaultValueRequest(
+ new String[] {"col"},
DTOConverters.toFunctionArg(converted));
+ UpdateTableColumnDefaultValueRequest restored =
+ JsonUtils.objectMapper()
+ .readValue(
+
JsonUtils.objectMapper().writeValueAsString(request),
+ UpdateTableColumnDefaultValueRequest.class);
+ String expectedSql =
+ target.equals("TIMESTAMP_LTZ")
+ ? "'"
+ + LocalDate.parse(date)
+ .atStartOfDay(ZoneId.of(timeZone))
+ .toInstant()
+ .atOffset(ZoneOffset.UTC)
+ .toString()
+ .replace("T", " ")
+ .replace("Z", "+00:00")
+ .replaceFirst(
+ "^(\\d{4}-\\d{2}-\\d{2}
\\d{2}:\\d{2})(?=[+-])", "$1:00")
+ + "'"
+ : "'" + date + " 00:00:00'";
+ Assertions.assertEquals(
+ expectedSql,
+ new JdbcColumnDefaultValueConverter()
+ .fromGravitino(restored.getNewDefaultValue()));
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+ }
+ });
+ }
+ }
+
+ @Test
+ void testStringDefaultsRejectTimestampTargets() {
+ for (Types.TimestampType target :
+ new Types.TimestampType[] {
+ Types.TimestampType.withTimeZone(),
Types.TimestampType.withoutTimeZone()
+ }) {
+ for (String sql :
+ new String[] {"'2026-11-04'", "'2026-11-04 12:34:56.123456'",
"'now'", "''"}) {
+ IllegalArgumentException error =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ sparkTableChangeConverter.toGravitinoTableChange(
+ TableChange.updateColumnDefaultValue(new String[]
{"col"}, sql),
+ () -> new Column[] {Column.of("col", target, null)}));
+ Assertions.assertTrue(error.getMessage().contains("col"));
+ Assertions.assertTrue(error.getMessage().contains("TIMESTAMP_LTZ or
TIMESTAMP_NTZ"));
+ }
+ }
+ assertDefault(
+ "'2026-11-04 12:34:56.123456'", Literals.stringLiteral("2026-11-04
12:34:56.123456"));
+ assertDefault("''", Literals.stringLiteral(""));
+ assertDefault("DATE '2026-11-04'", Literals.dateLiteral("2026-11-04"));
+ }
+
+ @Test
+ void testNestedTimestampTargetAndInvalidColumnPaths() {
+ Column[] columns =
+ new Column[] {
+ Column.of(
+ "parent",
+ Types.StructType.of(
+ Types.StructType.Field.nullableField(
+ "ts", Types.TimestampType.withoutTimeZone())),
+ null)
+ };
+ org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue converted =
+ (org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue)
+ sparkTableChangeConverter.toGravitinoTableChange(
+ TableChange.updateColumnDefaultValue(
+ new String[] {"parent", "ts"}, "TIMESTAMP_NTZ '2026-11-04
12:34:00'"),
+ () -> columns);
Assertions.assertEquals(
- Literals.stringLiteral(defaultValue),
- gravitinoUpdateColumnDefaultValue.getNewDefaultValue());
+ Literals.timestampLiteral("2026-11-04T12:34:00"),
converted.getNewDefaultValue());
+ for (String[] path :
+ new String[][] {{"missing"}, {"parent", "missing"}, {"parent", "ts",
"child"}}) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ sparkTableChangeConverter.toGravitinoTableChange(
+ TableChange.updateColumnDefaultValue(path, "TIMESTAMP_NTZ
'2026-11-04 12:34:00'"),
+ () -> columns));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"TIMESTAMP '2026-11-04 12:34:56'", "DATE
'2026-11-04'", "'2026-11-04'"})
+ void testTemporalDefaultRequiresTargetSchema(String sql) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ sparkTableChangeConverter.toGravitinoTableChange(
+ TableChange.updateColumnDefaultValue(new String[] {"col"},
sql)));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> assertDefault("TIMESTAMP '2026-11-04 12:34:56'",
Literals.stringLiteral("unused")));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"4142", "0080FF", ""})
+ void testBinaryLiteralDefaults(String hex) throws Exception {
+ Expression converted =
+ assertDefault(
+ "X'" + hex + "'", Literals.of(HexFormat.of().parseHex(hex),
Types.BinaryType.get()));
+ LiteralDTO dto = (LiteralDTO) DTOConverters.toFunctionArg(converted);
+ Assertions.assertEquals("0x" + hex.toLowerCase(Locale.ROOT), dto.value());
+ UpdateTableColumnDefaultValueRequest request =
+ new UpdateTableColumnDefaultValueRequest(new String[] {"col"}, dto);
+ UpdateTableColumnDefaultValueRequest restored =
+ JsonUtils.objectMapper()
+ .readValue(
+ JsonUtils.objectMapper().writeValueAsString(request),
+ UpdateTableColumnDefaultValueRequest.class);
+ Assertions.assertEquals(
+ "X'" + hex.toLowerCase(Locale.ROOT) + "'",
+ new
JdbcColumnDefaultValueConverter().fromGravitino(restored.getNewDefaultValue()));
+ }
+
+ private Expression assertDefault(String sql, Expression expected) {
+ org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue converted =
+ (org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue)
+ sparkTableChangeConverter.toGravitinoTableChange(
+ TableChange.updateColumnDefaultValue(new String[] {"col"},
sql),
+ () -> new Column[] {Column.of("col", ((Literal<?>)
expected).dataType(), null)});
+ Literal<?> expectedLiteral = (Literal<?>) expected;
+ if (expectedLiteral.value() instanceof byte[]) {
+ Literal<?> actual = (Literal<?>) converted.getNewDefaultValue();
+ Assertions.assertEquals(expectedLiteral.dataType(), actual.dataType());
+ Assertions.assertArrayEquals((byte[]) expectedLiteral.value(), (byte[])
actual.value());
+ } else {
+ Assertions.assertEquals(expected, converted.getNewDefaultValue());
+ }
+ return converted.getNewDefaultValue();
+ }
+
+ private void withSessionConfig(String timeZone, String timestampType,
Runnable assertions) {
+ SQLConf conf = new SQLConf();
+ conf.setConfString("spark.sql.session.timeZone", timeZone);
+ conf.setConfString("spark.sql.timestampType", timestampType);
+ SQLConf.withExistingConf(
+ conf,
+ () -> {
+ assertions.run();
+ return null;
+ });
}
}
diff --git
a/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogDefaults34.java
b/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogDefaults34.java
new file mode 100644
index 0000000000..a1c3b0d956
--- /dev/null
+++
b/spark-connector/v3.4/spark/src/test/java/org/apache/gravitino/spark/connector/catalog/TestBaseCatalogDefaults34.java
@@ -0,0 +1,284 @@
+/*
+ * 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.spark.connector.catalog;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.time.OffsetDateTime;
+import java.util.Map;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.client.GravitinoClient;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.TableCatalog;
+import org.apache.gravitino.rel.expressions.literals.Literals;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.spark.connector.PropertiesConverter;
+import org.apache.gravitino.spark.connector.SparkTableChangeConverter34;
+import org.apache.gravitino.spark.connector.SparkTransformConverter;
+import org.apache.gravitino.spark.connector.SparkTypeConverter;
+import org.apache.gravitino.spark.connector.SparkTypeConverter34;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.connector.catalog.TableChange;
+import org.apache.spark.sql.internal.SQLConf;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.util.CaseInsensitiveStringMap;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+/** Tests schema loading and typed defaults on Spark 3.4 and later. */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class TestBaseCatalogDefaults34 {
+
+ private Catalog gravitinoCatalog;
+ private TableCatalog gravitinoTableCatalog;
+ private org.apache.spark.sql.connector.catalog.TableCatalog sparkCatalog;
+ private TestCatalog catalog;
+
+ @BeforeAll
+ void initCatalogManager() {
+ GravitinoCatalogManager.create("user", () -> mock(GravitinoClient.class));
+ }
+
+ @AfterAll
+ void cleanupCatalogManager() {
+ GravitinoCatalogManager.get().close();
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ gravitinoCatalog = mock(Catalog.class);
+ gravitinoTableCatalog = mock(TableCatalog.class);
+ sparkCatalog =
mock(org.apache.spark.sql.connector.catalog.TableCatalog.class);
+ when(gravitinoCatalog.asTableCatalog()).thenReturn(gravitinoTableCatalog);
+ catalog = new TestCatalog(gravitinoCatalog, sparkCatalog);
+ Field converter =
BaseCatalog.class.getDeclaredField("sparkTableChangeConverter");
+ converter.setAccessible(true);
+ converter.set(catalog, new SparkTableChangeConverter34(new
SparkTypeConverter34()));
+ }
+
+ @Test
+ void testAlterTimestampDefaultLoadsTargetSchemaBeforeWriting() throws
Exception {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ org.apache.gravitino.rel.Table table =
mock(org.apache.gravitino.rel.Table.class);
+
when(gravitinoTableCatalog.loadTable(gravitinoIdentifier)).thenReturn(table);
+ when(table.columns())
+ .thenReturn(new Column[] {Column.of("ts",
Types.TimestampType.withoutTimeZone(6), null)});
+ Table sparkTable = mock(Table.class);
+ when(sparkCatalog.loadTable(identifier)).thenReturn(sparkTable);
+ SQLConf conf = new SQLConf();
+ conf.setConfString("spark.sql.session.timeZone", "Asia/Shanghai");
+ SQLConf.withExistingConf(
+ conf,
+ () -> {
+ Assertions.assertDoesNotThrow(
+ () ->
+ catalog.alterTable(
+ identifier,
+ TableChange.updateColumnDefaultValue(
+ new String[] {"ts"}, "TIMESTAMP_LTZ '2026-11-04
12:34:56.123456'")));
+ return null;
+ });
+ ArgumentCaptor<org.apache.gravitino.rel.TableChange> change =
+ ArgumentCaptor.forClass(org.apache.gravitino.rel.TableChange.class);
+ verify(gravitinoTableCatalog).alterTable(eq(gravitinoIdentifier),
change.capture());
+ Assertions.assertEquals(
+ Literals.timestampLiteral("2026-11-04T12:34:56.123456"),
+ ((org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue)
change.getValue())
+ .getNewDefaultValue());
+ verify(gravitinoTableCatalog).loadTable(gravitinoIdentifier);
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {"TIMESTAMP_LTZ '2026-11-04 12:34:00'", "DATE '2026-11-04'",
"'2026-11-04'"})
+ void testTemporalDefaultRejectsSchemaChangingBatchBeforeWriting(String sql) {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ IllegalArgumentException error =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ catalog.alterTable(
+ identifier,
+ TableChange.updateColumnDefaultValue(new String[] {"ts"},
sql),
+ TableChange.updateColumnType(new String[] {"ts"},
DataTypes.TimestampNTZType)));
+ Assertions.assertTrue(error.getMessage().contains("cannot be combined"));
+ Mockito.verifyNoInteractions(gravitinoTableCatalog, sparkCatalog);
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {"TIMESTAMP_LTZ '2026-11-04 12:34:00'", "DATE '2026-11-04'",
"'2026-11-04'"})
+ void testTemporalSchemaReadFailurePreventsWritingAndPreservesCause(String
sql) {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ ForbiddenException denied = new ForbiddenException("denied schema read");
+
when(gravitinoTableCatalog.loadTable(gravitinoIdentifier)).thenThrow(denied);
+ Assertions.assertSame(
+ denied,
+ Assertions.assertThrows(
+ ForbiddenException.class,
+ () ->
+ catalog.alterTable(
+ identifier, TableChange.updateColumnDefaultValue(new
String[] {"ts"}, sql))));
+ verify(gravitinoTableCatalog).loadTable(gravitinoIdentifier);
+ Mockito.verifyNoMoreInteractions(gravitinoTableCatalog);
+ Mockito.verifyNoInteractions(sparkCatalog);
+ }
+
+ @Test
+ void testDateDefaultUsesSessionZoneBeforeWriting() throws Exception {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ org.apache.gravitino.rel.Table table =
mock(org.apache.gravitino.rel.Table.class);
+
when(gravitinoTableCatalog.loadTable(gravitinoIdentifier)).thenReturn(table);
+ when(table.columns())
+ .thenReturn(new Column[] {Column.of("ts",
Types.TimestampType.withTimeZone(), null)});
+ SQLConf conf = new SQLConf();
+ conf.setConfString("spark.sql.session.timeZone", "Asia/Shanghai");
+ SQLConf.withExistingConf(
+ conf,
+ () -> {
+ Assertions.assertDoesNotThrow(
+ () ->
+ catalog.alterTable(
+ identifier,
+ TableChange.updateColumnDefaultValue(
+ new String[] {"ts"}, "DATE '2026-11-04'")));
+ return null;
+ });
+ ArgumentCaptor<org.apache.gravitino.rel.TableChange> change =
+ ArgumentCaptor.forClass(org.apache.gravitino.rel.TableChange.class);
+ verify(gravitinoTableCatalog).alterTable(eq(gravitinoIdentifier),
change.capture());
+ Assertions.assertEquals(
+ Literals.of(
+ OffsetDateTime.parse("2026-11-03T16:00:00Z"),
Types.TimestampType.withTimeZone()),
+ ((org.apache.gravitino.rel.TableChange.UpdateColumnDefaultValue)
change.getValue())
+ .getNewDefaultValue());
+ verify(gravitinoTableCatalog).loadTable(gravitinoIdentifier);
+ }
+
+ @Test
+ void testStringTimestampDefaultRejectedBeforeWriting() {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ org.apache.gravitino.rel.Table table =
mock(org.apache.gravitino.rel.Table.class);
+
when(gravitinoTableCatalog.loadTable(gravitinoIdentifier)).thenReturn(table);
+ when(table.columns())
+ .thenReturn(new Column[] {Column.of("ts",
Types.TimestampType.withTimeZone(), null)});
+ IllegalArgumentException error =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ catalog.alterTable(
+ identifier,
+ TableChange.updateColumnDefaultValue(
+ new String[] {"ts"}, "'2026-11-04 12:34:56.123456'")));
+ Assertions.assertTrue(error.getMessage().contains("TIMESTAMP_LTZ or
TIMESTAMP_NTZ"));
+ verify(gravitinoTableCatalog).loadTable(gravitinoIdentifier);
+ Mockito.verifyNoMoreInteractions(gravitinoTableCatalog);
+ Mockito.verifyNoInteractions(sparkCatalog);
+ }
+
+ @Test
+ void testNumericDefaultDoesNotReadSchema() throws Exception {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ catalog.alterTable(identifier, TableChange.updateColumnDefaultValue(new
String[] {"ts"}, "42"));
+ verify(gravitinoTableCatalog,
Mockito.never()).loadTable(gravitinoIdentifier);
+ }
+
+ @Test
+ void testTemporalDefaultsShareOneSchemaRead() throws Exception {
+ Identifier identifier = Identifier.of(new String[] {"schema"}, "table_a");
+ NameIdentifier gravitinoIdentifier = NameIdentifier.of("schema",
"table_a");
+ org.apache.gravitino.rel.Table table =
mock(org.apache.gravitino.rel.Table.class);
+
when(gravitinoTableCatalog.loadTable(gravitinoIdentifier)).thenReturn(table);
+ when(table.columns())
+ .thenReturn(
+ new Column[] {
+ Column.of("ts", Types.TimestampType.withoutTimeZone(), null),
+ Column.of("label", Types.StringType.get(), null)
+ });
+ catalog.alterTable(
+ identifier,
+ TableChange.updateColumnDefaultValue(
+ new String[] {"ts"}, "TIMESTAMP_NTZ '2026-11-04 12:34:00'"),
+ TableChange.updateColumnDefaultValue(
+ new String[] {"ts"}, "TIMESTAMP_NTZ '2026-11-04 00:00:00'"),
+ TableChange.updateColumnDefaultValue(new String[] {"ts"}, "DATE
'2026-11-04'"),
+ TableChange.updateColumnDefaultValue(new String[] {"label"},
"'label'"));
+ verify(gravitinoTableCatalog).loadTable(gravitinoIdentifier);
+ verify(table).columns();
+ }
+
+ private static class TestCatalog extends BaseCatalog {
+
+ private TestCatalog(
+ Catalog gravitinoCatalog,
+ org.apache.spark.sql.connector.catalog.TableCatalog sparkCatalog) {
+ this.gravitinoCatalogClient = gravitinoCatalog;
+ this.sparkCatalog = sparkCatalog;
+ }
+
+ @Override
+ protected org.apache.spark.sql.connector.catalog.TableCatalog
createAndInitSparkCatalog(
+ String name, CaseInsensitiveStringMap options, Map<String, String>
properties) {
+ return sparkCatalog;
+ }
+
+ @Override
+ protected Table createSparkTable(
+ Identifier identifier,
+ org.apache.gravitino.rel.Table gravitinoTable,
+ Table sparkTable,
+ org.apache.spark.sql.connector.catalog.TableCatalog sparkCatalog,
+ PropertiesConverter propertiesConverter,
+ SparkTransformConverter sparkTransformConverter,
+ SparkTypeConverter sparkTypeConverter) {
+ return sparkTable;
+ }
+
+ @Override
+ protected PropertiesConverter getPropertiesConverter() {
+ return mock(PropertiesConverter.class);
+ }
+
+ @Override
+ protected SparkTransformConverter getSparkTransformConverter() {
+ return mock(SparkTransformConverter.class);
+ }
+ }
+}