From 2c48a211147cdd538d211dbe54c67b7ad69e955a Mon Sep 17 00:00:00 2001 From: jackylee-ch Date: Mon, 7 Sep 2026 11:29:37 +0800 Subject: [PATCH] [common][flink] Fix missing cast rule for DATE/TIME/TIMESTAMP to bounded CHAR/VARCHAR DateToStringCastRule, TimeToStringCastRule and TimestampToStringCastRule registered VarCharType.STRING_TYPE as their target, so CastExecutors only matched a type equal to it. A bounded VARCHAR/CHAR target, or a plain STRING NOT NULL, resolved to no rule, and SchemaManagerUtils rejects the column type change when the executor is null even though DataTypeCasts.supportsCast allows it. Key them on DataTypeFamily.CHARACTER_STRING, which the class javadoc here already claims, and trim or pad through BinaryStringUtils like the numeric and boolean rules. Bounded character targets already truncate and blank pad for the other scalar types, asserted since 47d4dd6fd. --- .../paimon/casting/DateToStringCastRule.java | 19 +++- .../paimon/casting/TimeToStringCastRule.java | 19 ++-- .../casting/TimestampToStringCastRule.java | 17 +++- ...DateTimeToCharacterStringCastRuleTest.java | 97 +++++++++++++++++++ .../paimon/flink/SchemaChangeITCase.java | 31 ++++++ 5 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 paimon-common/src/test/java/org/apache/paimon/casting/DateTimeToCharacterStringCastRuleTest.java diff --git a/paimon-common/src/main/java/org/apache/paimon/casting/DateToStringCastRule.java b/paimon-common/src/main/java/org/apache/paimon/casting/DateToStringCastRule.java index 5ec3391eaab8..94eea00a71ec 100644 --- a/paimon-common/src/main/java/org/apache/paimon/casting/DateToStringCastRule.java +++ b/paimon-common/src/main/java/org/apache/paimon/casting/DateToStringCastRule.java @@ -20,23 +20,34 @@ import org.apache.paimon.data.BinaryString; import org.apache.paimon.types.DataType; +import org.apache.paimon.types.DataTypeChecks; import org.apache.paimon.types.DataTypeFamily; import org.apache.paimon.types.DataTypeRoot; +import org.apache.paimon.types.VarCharType; +import org.apache.paimon.utils.BinaryStringUtils; import org.apache.paimon.utils.DateTimeUtils; -import static org.apache.paimon.types.VarCharType.STRING_TYPE; - /** {@link DataTypeRoot#DATE} to {@link DataTypeFamily#CHARACTER_STRING} cast rule. */ class DateToStringCastRule extends AbstractCastRule { static final DateToStringCastRule INSTANCE = new DateToStringCastRule(); private DateToStringCastRule() { - super(CastRulePredicate.builder().input(DataTypeRoot.DATE).target(STRING_TYPE).build()); + super( + CastRulePredicate.builder() + .input(DataTypeRoot.DATE) + .target(DataTypeFamily.CHARACTER_STRING) + .build()); } @Override public CastExecutor create(DataType inputType, DataType targetType) { - return value -> BinaryString.fromString(DateTimeUtils.formatDate(value)); + boolean padOrTrim = + targetType.is(DataTypeRoot.CHAR) + || DataTypeChecks.getLength(targetType) != VarCharType.MAX_LENGTH; + return value -> { + BinaryString result = BinaryString.fromString(DateTimeUtils.formatDate(value)); + return padOrTrim ? BinaryStringUtils.toCharacterString(result, targetType) : result; + }; } } diff --git a/paimon-common/src/main/java/org/apache/paimon/casting/TimeToStringCastRule.java b/paimon-common/src/main/java/org/apache/paimon/casting/TimeToStringCastRule.java index d0f8ba768048..f61f15c74e23 100644 --- a/paimon-common/src/main/java/org/apache/paimon/casting/TimeToStringCastRule.java +++ b/paimon-common/src/main/java/org/apache/paimon/casting/TimeToStringCastRule.java @@ -23,10 +23,10 @@ import org.apache.paimon.types.DataTypeChecks; import org.apache.paimon.types.DataTypeFamily; import org.apache.paimon.types.DataTypeRoot; +import org.apache.paimon.types.VarCharType; +import org.apache.paimon.utils.BinaryStringUtils; import org.apache.paimon.utils.DateTimeUtils; -import static org.apache.paimon.types.VarCharType.STRING_TYPE; - /** * {@link DataTypeRoot#TIME_WITHOUT_TIME_ZONE} to {@link DataTypeFamily#CHARACTER_STRING} cast rule. */ @@ -38,15 +38,20 @@ private TimeToStringCastRule() { super( CastRulePredicate.builder() .input(DataTypeRoot.TIME_WITHOUT_TIME_ZONE) - .target(STRING_TYPE) + .target(DataTypeFamily.CHARACTER_STRING) .build()); } @Override public CastExecutor create(DataType inputType, DataType targetType) { - return value -> - BinaryString.fromString( - DateTimeUtils.formatTimestampMillis( - value, DataTypeChecks.getPrecision(inputType))); + final int precision = DataTypeChecks.getPrecision(inputType); + boolean padOrTrim = + targetType.is(DataTypeRoot.CHAR) + || DataTypeChecks.getLength(targetType) != VarCharType.MAX_LENGTH; + return value -> { + BinaryString result = + BinaryString.fromString(DateTimeUtils.formatTimestampMillis(value, precision)); + return padOrTrim ? BinaryStringUtils.toCharacterString(result, targetType) : result; + }; } } diff --git a/paimon-common/src/main/java/org/apache/paimon/casting/TimestampToStringCastRule.java b/paimon-common/src/main/java/org/apache/paimon/casting/TimestampToStringCastRule.java index 18016edc69a0..f433e559c36f 100644 --- a/paimon-common/src/main/java/org/apache/paimon/casting/TimestampToStringCastRule.java +++ b/paimon-common/src/main/java/org/apache/paimon/casting/TimestampToStringCastRule.java @@ -24,12 +24,12 @@ import org.apache.paimon.types.DataTypeChecks; import org.apache.paimon.types.DataTypeFamily; import org.apache.paimon.types.DataTypeRoot; +import org.apache.paimon.types.VarCharType; +import org.apache.paimon.utils.BinaryStringUtils; import org.apache.paimon.utils.DateTimeUtils; import java.util.TimeZone; -import static org.apache.paimon.types.VarCharType.STRING_TYPE; - /** {@link DataTypeFamily#TIMESTAMP} to {@link DataTypeFamily#CHARACTER_STRING} cast rule. */ class TimestampToStringCastRule extends AbstractCastRule { @@ -39,7 +39,7 @@ private TimestampToStringCastRule() { super( CastRulePredicate.builder() .input(DataTypeFamily.TIMESTAMP) - .target(STRING_TYPE) + .target(DataTypeFamily.CHARACTER_STRING) .build()); } @@ -50,7 +50,14 @@ public CastExecutor create(DataType inputType, DataType inputType.is(DataTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE) ? TimeZone.getDefault() : DateTimeUtils.UTC_ZONE; - return value -> - BinaryString.fromString(DateTimeUtils.formatTimestamp(value, timeZone, precision)); + boolean padOrTrim = + targetType.is(DataTypeRoot.CHAR) + || DataTypeChecks.getLength(targetType) != VarCharType.MAX_LENGTH; + return value -> { + BinaryString result = + BinaryString.fromString( + DateTimeUtils.formatTimestamp(value, timeZone, precision)); + return padOrTrim ? BinaryStringUtils.toCharacterString(result, targetType) : result; + }; } } diff --git a/paimon-common/src/test/java/org/apache/paimon/casting/DateTimeToCharacterStringCastRuleTest.java b/paimon-common/src/test/java/org/apache/paimon/casting/DateTimeToCharacterStringCastRuleTest.java new file mode 100644 index 000000000000..f6ebb7d36454 --- /dev/null +++ b/paimon-common/src/test/java/org/apache/paimon/casting/DateTimeToCharacterStringCastRuleTest.java @@ -0,0 +1,97 @@ +/* + * 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.paimon.casting; + +import org.apache.paimon.data.BinaryString; +import org.apache.paimon.data.Timestamp; +import org.apache.paimon.types.CharType; +import org.apache.paimon.types.DataType; +import org.apache.paimon.types.DateType; +import org.apache.paimon.types.LocalZonedTimestampType; +import org.apache.paimon.types.TimeType; +import org.apache.paimon.types.TimestampType; +import org.apache.paimon.types.VarCharType; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests casting DATE, TIME and TIMESTAMP to a bounded or non-nullable character string. Those + * targets resolve to no rule until the rules are keyed on the string family, and the unbounded + * cases cover the branch that skips the trim and pad. + */ +public class DateTimeToCharacterStringCastRuleTest { + + @Test + public void testDateToBoundedString() { + DateType date = new DateType(); + + assertThat(cast(date, new VarCharType(4), 0)).isEqualTo("1970"); + assertThat(cast(date, new CharType(12), 0)).isEqualTo("1970-01-01 "); + assertThat(cast(date, VarCharType.STRING_TYPE, 0)).isEqualTo("1970-01-01"); + assertThat(cast(date, VarCharType.stringType(false), 0)).isEqualTo("1970-01-01"); + } + + @Test + public void testTimeToBoundedString() { + assertThat(cast(new TimeType(0), new VarCharType(5), 3661000)).isEqualTo("01:01"); + assertThat(cast(new TimeType(0), new CharType(10), 3661000)).isEqualTo("01:01:01 "); + assertThat(cast(new TimeType(0), VarCharType.STRING_TYPE, 3661000)).isEqualTo("01:01:01"); + + // the input precision has to survive: a bounded target must cut the fraction, not the rule + assertThat(cast(new TimeType(3), VarCharType.STRING_TYPE, 3661123)) + .isEqualTo("01:01:01.123"); + assertThat(cast(new TimeType(3), new VarCharType(8), 3661123)).isEqualTo("01:01:01"); + } + + @Test + public void testTimestampToBoundedString() { + TimestampType timestamp = new TimestampType(3); + Timestamp value = Timestamp.fromEpochMillis(0); + + assertThat(cast(timestamp, new VarCharType(10), value)).isEqualTo("1970-01-01"); + assertThat(cast(timestamp, new CharType(25), value)).isEqualTo("1970-01-01 00:00:00.000 "); + assertThat(cast(timestamp, VarCharType.STRING_TYPE, value)) + .isEqualTo("1970-01-01 00:00:00.000"); + assertThat(cast(timestamp, VarCharType.stringType(false), value)) + .isEqualTo("1970-01-01 00:00:00.000"); + } + + @Test + public void testLocalZonedTimestampToBoundedString() { + // this input keeps the default time zone rather than UTC, so pin the bounded result + // against the unbounded one instead of a fixed instant + LocalZonedTimestampType ltz = new LocalZonedTimestampType(3); + Timestamp value = Timestamp.fromEpochMillis(0); + + String unbounded = cast(ltz, VarCharType.STRING_TYPE, value); + assertThat(unbounded).hasSize(23); + assertThat(cast(ltz, new VarCharType(10), value)).isEqualTo(unbounded.substring(0, 10)); + assertThat(cast(ltz, new CharType(25), value)).isEqualTo(unbounded + " "); + } + + @SuppressWarnings("unchecked") + private static String cast(DataType inputType, DataType targetType, T value) { + CastExecutor executor = + (CastExecutor) CastExecutors.resolve(inputType, targetType); + assertThat(executor).as("no cast rule for %s to %s", inputType, targetType).isNotNull(); + return executor.cast(value).toString(); + } +} diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SchemaChangeITCase.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SchemaChangeITCase.java index 43d817477f63..9a0b6cb06647 100644 --- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SchemaChangeITCase.java +++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SchemaChangeITCase.java @@ -354,6 +354,37 @@ public void testModifyColumnTypeFromTimestampToString() { + "]"); } + @Test + public void testModifyColumnTypeFromTimestampToBoundedString() { + // a bounded CHAR/VARCHAR target has to resolve to the same rule, then trim or pad + sql( + "CREATE TABLE T (a STRING PRIMARY KEY NOT ENFORCED, b TIMESTAMP(3), d DATE, f TIME, g TIMESTAMP(3) WITH LOCAL TIME ZONE)"); + sql( + "INSERT INTO T VALUES('paimon', TIMESTAMP '2023-06-06 12:00:00', DATE '2023-05-31', TIME '14:30:00', TO_TIMESTAMP_LTZ(4001, 3))"); + + sql("ALTER TABLE T MODIFY (b VARCHAR(10), d CHAR(12), f VARCHAR(5), g VARCHAR(10))"); + List result = sql("SHOW CREATE TABLE T"); + assertThat(result.toString()) + .contains( + "CREATE TABLE `PAIMON`.`default`.`T` (\n" + + " `a` VARCHAR(2147483647) NOT NULL,\n" + + " `b` VARCHAR(10),\n" + + " `d` CHAR(12),\n" + + " `f` VARCHAR(5),\n" + + " `g` VARCHAR(10),"); + String localZoned = + DateTimeUtils.formatTimestamp( + DateTimeUtils.parseTimestampData("1970-01-01 00:00:04.001", 3), + TimeZone.getDefault(), + 3); + result = sql("SELECT * FROM T"); + assertThat(result.stream().map(Objects::toString).collect(Collectors.toList())) + .containsExactly( + "+I[paimon, 2023-06-06, 2023-05-31 , 14:30, " + + localZoned.substring(0, 10) + + "]"); + } + @Test public void testModifyColumnTypeFromStringToString() { sql("CREATE TABLE T (b VARCHAR(10), c VARCHAR(10), d CHAR(5), e CHAR(5))");