diff --git a/fluss-client/src/main/java/org/apache/fluss/client/converter/ConverterCommons.java b/fluss-client/src/main/java/org/apache/fluss/client/converter/ConverterCommons.java index 82415403f4..b608c42e13 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/converter/ConverterCommons.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/converter/ConverterCommons.java @@ -148,6 +148,15 @@ static void validateCompatibility(DataType fieldType, PojoType.Property prop) { } return; } + if (actual.isEnum()) { + if (typeRoot != DataTypeRoot.STRING) { + throw new IllegalArgumentException( + String.format( + "Enum field '%s' must be a string type, got %s", + prop.name, typeRoot)); + } + return; + } Set> supported = SUPPORTED_TYPES.get(fieldType.getTypeRoot()); if (supported == null) { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverter.java b/fluss-client/src/main/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverter.java index 2b693f4e35..14a6078ab4 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverter.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverter.java @@ -31,9 +31,30 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** Shared utilities for Fluss type and Pojo type. */ public class FlussTypeToPojoTypeConverter { + + private static final Map, Map> ENUM_CONSTANTS_CACHE = + new ConcurrentHashMap<>(); + + /** + * Builds a map of enum name (uppercase) to enum constant for the given enum class. This map is + * cached to avoid recreating it for every enum conversion. + */ + private static Map buildEnumConstantsMap(Class enumClass) { + Map map = new HashMap<>(); + for (Object constant : enumClass.getEnumConstants()) { + map.put(constant.toString(), constant); + } + return Collections.unmodifiableMap(map); + } + /** * Converts a text value (CHAR/STRING) read from an InternalRow into the target Java type * declared by the POJO property. @@ -74,6 +95,18 @@ static Object convertTextValue( ConverterCommons.charLengthExceptionMessage(fieldName, v.length())); } return v.charAt(0); + } else if (pojoType.isEnum()) { + Map enumMap = + ENUM_CONSTANTS_CACHE.computeIfAbsent( + pojoType, FlussTypeToPojoTypeConverter::buildEnumConstantsMap); + Object enumConstant = enumMap.get(v); + if (enumConstant == null) { + throw new IllegalArgumentException( + String.format( + "Could not parse value for enum %s. Expected one of: %s", + pojoType, Arrays.toString(pojoType.getEnumConstants()))); + } + return enumConstant; } throw new IllegalArgumentException( String.format( diff --git a/fluss-client/src/test/java/org/apache/fluss/client/converter/ConverterCommonsTest.java b/fluss-client/src/test/java/org/apache/fluss/client/converter/ConverterCommonsTest.java new file mode 100644 index 0000000000..b0ca06e25a --- /dev/null +++ b/fluss-client/src/test/java/org/apache/fluss/client/converter/ConverterCommonsTest.java @@ -0,0 +1,633 @@ +/* + * 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.fluss.client.converter; + +import org.apache.fluss.row.BinaryString; +import org.apache.fluss.types.DataTypes; +import org.apache.fluss.types.RowType; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for {@link ConverterCommons}. */ +public class ConverterCommonsTest { + + // ==================== validatePojoMatchesTable Tests ==================== + + @Test + public void validatePojoMatchesTableWithExactMatch() { + RowType table = + RowType.builder() + .field("id", DataTypes.INT()) + .field("name", DataTypes.STRING()) + .build(); + + PojoType pojoType = PojoType.of(AllFieldsPojo.class); + ConverterCommons.validatePojoMatchesTable(pojoType, table); + } + + @Test + public void validatePojoMatchesTableWithTypeIncompatibility() { + RowType table = + RowType.builder() + .field("id", DataTypes.INT()) + .field("name", DataTypes.INT()) + .build(); + + PojoType pojoType = PojoType.of(AllFieldsPojo.class); + assertThatThrownBy(() -> ConverterCommons.validatePojoMatchesTable(pojoType, table)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("incompatible with Fluss type"); + } + + @Test + public void validatePojoMatchesTableWithMissingField() { + RowType table = + RowType.builder() + .field("id", DataTypes.INT()) + .field("name", DataTypes.STRING()) + .field("age", DataTypes.INT()) + .build(); + + PojoType pojoType = PojoType.of(AllFieldsPojo.class); + assertThatThrownBy(() -> ConverterCommons.validatePojoMatchesTable(pojoType, table)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must exactly match"); + } + + // ==================== validatePojoMatchesProjection Tests ==================== + + @Test + public void validatePojoMatchesProjectionWithSubset() { + RowType projection = + RowType.builder() + .field("id", DataTypes.INT()) + .field("name", DataTypes.STRING()) + .build(); + + PojoType pojoType = PojoType.of(AllFieldsPojo.class); + ConverterCommons.validatePojoMatchesProjection(pojoType, projection); + } + + @Test + public void validatePojoMatchesProjectionWithSingleField() { + RowType projection = RowType.builder().field("id", DataTypes.INT()).build(); + + PojoType pojoType = PojoType.of(AllFieldsPojo.class); + ConverterCommons.validatePojoMatchesProjection(pojoType, projection); + } + + @Test + public void validatePojoMatchesProjectionWithMissingField() { + RowType projection = + RowType.builder() + .field("id", DataTypes.INT()) + .field("missingField", DataTypes.STRING()) + .build(); + + PojoType pojoType = PojoType.of(AllFieldsPojo.class); + assertThatThrownBy( + () -> ConverterCommons.validatePojoMatchesProjection(pojoType, projection)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void validatePojoMatchesProjectionWithTypeIncompatibility() { + RowType projection = + RowType.builder() + .field("id", DataTypes.STRING()) + .field("name", DataTypes.STRING()) + .build(); + + PojoType pojoType = PojoType.of(AllFieldsPojo.class); + assertThatThrownBy( + () -> ConverterCommons.validatePojoMatchesProjection(pojoType, projection)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ==================== validateProjectionSubset Tests ==================== + + @Test + public void validateProjectionSubsetAllFieldsInTable() { + RowType tableSchema = + RowType.builder() + .field("id", DataTypes.INT()) + .field("name", DataTypes.STRING()) + .field("age", DataTypes.INT()) + .build(); + + RowType projection = + RowType.builder() + .field("id", DataTypes.INT()) + .field("name", DataTypes.STRING()) + .build(); + + ConverterCommons.validateProjectionSubset(projection, tableSchema); + } + + @Test + public void validateProjectionSubsetSingleFieldInTable() { + RowType tableSchema = + RowType.builder() + .field("id", DataTypes.INT()) + .field("name", DataTypes.STRING()) + .build(); + + RowType projection = RowType.builder().field("id", DataTypes.INT()).build(); + + ConverterCommons.validateProjectionSubset(projection, tableSchema); + } + + @Test + public void validateProjectionSubsetWithFieldNotInTable() { + RowType tableSchema = + RowType.builder() + .field("id", DataTypes.INT()) + .field("name", DataTypes.STRING()) + .build(); + + RowType projection = + RowType.builder() + .field("id", DataTypes.INT()) + .field("unknown", DataTypes.STRING()) + .build(); + + assertThatThrownBy(() -> ConverterCommons.validateProjectionSubset(projection, tableSchema)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ==================== validateCompatibility Tests ==================== + + @Test + public void compatibilityBooleanWithBoolean() { + PojoType pojoType = PojoType.of(BooleanPojo.class); + PojoType.Property prop = pojoType.getProperty("flag"); + ConverterCommons.validateCompatibility(DataTypes.BOOLEAN(), prop); + } + + @Test + public void compatibilityTinyintWithByte() { + PojoType pojoType = PojoType.of(BytePojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.TINYINT(), prop); + } + + @Test + public void compatibilitySmallintWithShort() { + PojoType pojoType = PojoType.of(ShortPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.SMALLINT(), prop); + } + + @Test + public void compatibilityIntegerWithInteger() { + PojoType pojoType = PojoType.of(IntPojo.class); + PojoType.Property prop = pojoType.getProperty("id"); + ConverterCommons.validateCompatibility(DataTypes.INT(), prop); + } + + @Test + public void compatibilityBigintWithLong() { + PojoType pojoType = PojoType.of(LongPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.BIGINT(), prop); + } + + @Test + public void compatibilityFloatWithFloat() { + PojoType pojoType = PojoType.of(FloatPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.FLOAT(), prop); + } + + @Test + public void compatibilityDoubleWithDouble() { + PojoType pojoType = PojoType.of(DoublePojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.DOUBLE(), prop); + } + + @Test + public void compatibilityCharWithString() { + PojoType pojoType = PojoType.of(StringPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.CHAR(10), prop); + } + + @Test + public void compatibilityCharWithCharacter() { + PojoType pojoType = PojoType.of(CharacterPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.CHAR(1), prop); + } + + @Test + public void compatibilityStringWithString() { + PojoType pojoType = PojoType.of(StringPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.STRING(), prop); + } + + @Test + public void compatibilityStringWithCharacter() { + PojoType pojoType = PojoType.of(CharacterPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + ConverterCommons.validateCompatibility(DataTypes.STRING(), prop); + } + + @Test + public void compatibilityBinaryWithByteArray() { + PojoType pojoType = PojoType.of(ByteArrayPojo.class); + PojoType.Property prop = pojoType.getProperty("bytes"); + ConverterCommons.validateCompatibility(DataTypes.BINARY(100), prop); + } + + @Test + public void compatibilityBytesWithByteArray() { + PojoType pojoType = PojoType.of(ByteArrayPojo.class); + PojoType.Property prop = pojoType.getProperty("bytes"); + ConverterCommons.validateCompatibility(DataTypes.BYTES(), prop); + } + + @Test + public void compatibilityDecimalWithBigDecimal() { + PojoType pojoType = PojoType.of(BigDecimalPojo.class); + PojoType.Property prop = pojoType.getProperty("amount"); + ConverterCommons.validateCompatibility(DataTypes.DECIMAL(10, 2), prop); + } + + @Test + public void compatibilityDateWithLocalDate() { + PojoType pojoType = PojoType.of(LocalDatePojo.class); + PojoType.Property prop = pojoType.getProperty("date"); + ConverterCommons.validateCompatibility(DataTypes.DATE(), prop); + } + + @Test + public void compatibilityTimeWithLocalTime() { + PojoType pojoType = PojoType.of(LocalTimePojo.class); + PojoType.Property prop = pojoType.getProperty("time"); + ConverterCommons.validateCompatibility(DataTypes.TIME(), prop); + } + + @Test + public void compatibilityTimestampNtzWithLocalDateTime() { + PojoType pojoType = PojoType.of(LocalDateTimePojo.class); + PojoType.Property prop = pojoType.getProperty("timestamp"); + ConverterCommons.validateCompatibility(DataTypes.TIMESTAMP(), prop); + } + + @Test + public void compatibilityTimestampLtzWithInstant() { + PojoType pojoType = PojoType.of(InstantPojo.class); + PojoType.Property prop = pojoType.getProperty("timestamp"); + ConverterCommons.validateCompatibility(DataTypes.TIMESTAMP_LTZ(), prop); + } + + @Test + public void compatibilityTimestampLtzWithOffsetDateTime() { + PojoType pojoType = PojoType.of(OffsetDateTimePojo.class); + PojoType.Property prop = pojoType.getProperty("timestamp"); + ConverterCommons.validateCompatibility(DataTypes.TIMESTAMP_LTZ(), prop); + } + + @Test + public void compatibilityArrayWithArrayType() { + PojoType pojoType = PojoType.of(IntArrayPojo.class); + PojoType.Property prop = pojoType.getProperty("items"); + ConverterCommons.validateCompatibility(DataTypes.ARRAY(DataTypes.INT()), prop); + } + + @Test + public void compatibilityArrayWithListType() { + PojoType pojoType = PojoType.of(ListPojo.class); + PojoType.Property prop = pojoType.getProperty("items"); + ConverterCommons.validateCompatibility(DataTypes.ARRAY(DataTypes.INT()), prop); + } + + @Test + public void compatibilityMapType() { + PojoType pojoType = PojoType.of(MapPojo.class); + PojoType.Property prop = pojoType.getProperty("mapping"); + ConverterCommons.validateCompatibility( + DataTypes.MAP(DataTypes.STRING(), DataTypes.INT()), prop); + } + + @Test + public void compatibilityRowWithNestedPojo() { + PojoType pojoType = PojoType.of(NestedPojo.class); + PojoType.Property prop = pojoType.getProperty("nested"); + ConverterCommons.validateCompatibility( + DataTypes.ROW( + DataTypes.FIELD("id", DataTypes.INT()), + DataTypes.FIELD("name", DataTypes.STRING())), + prop); + } + + @Test + public void compatibilityEnumWithString() { + PojoType pojoType = PojoType.of(EnumPojo.class); + PojoType.Property prop = pojoType.getProperty("status"); + ConverterCommons.validateCompatibility(DataTypes.STRING(), prop); + } + + @Test + public void incompatibilityBooleanWithString() { + PojoType pojoType = PojoType.of(StringPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + assertThatThrownBy(() -> ConverterCommons.validateCompatibility(DataTypes.BOOLEAN(), prop)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("incompatible"); + } + + @Test + public void incompatibilityIntegerWithString() { + PojoType pojoType = PojoType.of(StringPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + assertThatThrownBy(() -> ConverterCommons.validateCompatibility(DataTypes.INT(), prop)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("incompatible"); + } + + @Test + public void incompatibilityArrayWithString() { + PojoType pojoType = PojoType.of(StringPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + assertThatThrownBy( + () -> + ConverterCommons.validateCompatibility( + DataTypes.ARRAY(DataTypes.INT()), prop)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be an array or Collection"); + } + + @Test + public void incompatibilityMapWithString() { + PojoType pojoType = PojoType.of(StringPojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + assertThatThrownBy( + () -> + ConverterCommons.validateCompatibility( + DataTypes.MAP(DataTypes.STRING(), DataTypes.INT()), prop)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a Map"); + } + + @Test + public void incompatibilityRowWithArray() { + PojoType pojoType = PojoType.of(IntArrayPojo.class); + PojoType.Property prop = pojoType.getProperty("items"); + assertThatThrownBy( + () -> + ConverterCommons.validateCompatibility( + DataTypes.ROW(DataTypes.FIELD("id", DataTypes.INT())), + prop)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a POJO class"); + } + + @Test + public void incompatibilityRowWithList() { + PojoType pojoType = PojoType.of(ListPojo.class); + PojoType.Property prop = pojoType.getProperty("items"); + assertThatThrownBy( + () -> + ConverterCommons.validateCompatibility( + DataTypes.ROW(DataTypes.FIELD("id", DataTypes.INT())), + prop)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a POJO class"); + } + + @Test + public void incompatibilityEnumWithInt() { + PojoType pojoType = PojoType.of(EnumPojo.class); + PojoType.Property prop = pojoType.getProperty("status"); + assertThatThrownBy(() -> ConverterCommons.validateCompatibility(DataTypes.INT(), prop)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a string type"); + } + + @Test + public void incompatibilityBigintWithByte() { + PojoType pojoType = PojoType.of(BytePojo.class); + PojoType.Property prop = pojoType.getProperty("value"); + assertThatThrownBy(() -> ConverterCommons.validateCompatibility(DataTypes.BIGINT(), prop)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("incompatible"); + } + + @Test + public void convertStringToBinaryStringForCharTypeWithWrongLengthThrows() { + assertThatThrownBy( + () -> + ConverterCommons.toBinaryStringForText( + "Hello", "testField", DataTypes.CHAR(1).getTypeRoot())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void convertBooleanToBinaryString() { + BinaryString result = + ConverterCommons.toBinaryStringForText( + true, "field", DataTypes.STRING().getTypeRoot()); + assertThat(result.toString()).isEqualTo("true"); + } + + @Test + public void convertNullToBinaryString() { + BinaryString result = + ConverterCommons.toBinaryStringForText( + null, "field", DataTypes.STRING().getTypeRoot()); + assertThat(result.toString()).isEqualTo("null"); + } + + // ==================== Test POJOs ==================== + + /** Test POJO with multiple fields. */ + public static class AllFieldsPojo { + public Integer id; + public String name; + + public AllFieldsPojo() {} + } + + /** Test POJO with Boolean field. */ + public static class BooleanPojo { + public Boolean flag; + + public BooleanPojo() {} + } + + /** Test POJO with Byte field. */ + public static class BytePojo { + public Byte value; + + public BytePojo() {} + } + + /** Test POJO with Short field. */ + public static class ShortPojo { + public Short value; + + public ShortPojo() {} + } + + /** Test POJO with Integer field. */ + public static class IntPojo { + public Integer id; + + public IntPojo() {} + } + + /** Test POJO with Long field. */ + public static class LongPojo { + public Long value; + + public LongPojo() {} + } + + /** Test POJO with Float field. */ + public static class FloatPojo { + public Float value; + + public FloatPojo() {} + } + + /** Test POJO with Double field. */ + public static class DoublePojo { + public Double value; + + public DoublePojo() {} + } + + /** Test POJO with String field. */ + public static class StringPojo { + public String value; + + public StringPojo() {} + } + + /** Test POJO with Character field. */ + public static class CharacterPojo { + public Character value; + + public CharacterPojo() {} + } + + /** Test POJO with byte[] field. */ + public static class ByteArrayPojo { + public byte[] bytes; + + public ByteArrayPojo() {} + } + + /** Test POJO with BigDecimal field. */ + public static class BigDecimalPojo { + public BigDecimal amount; + + public BigDecimalPojo() {} + } + + /** Test POJO with LocalDate field. */ + public static class LocalDatePojo { + public LocalDate date; + + public LocalDatePojo() {} + } + + /** Test POJO with LocalTime field. */ + public static class LocalTimePojo { + public LocalTime time; + + public LocalTimePojo() {} + } + + /** Test POJO with LocalDateTime field. */ + public static class LocalDateTimePojo { + public LocalDateTime timestamp; + + public LocalDateTimePojo() {} + } + + /** Test POJO with Instant field. */ + public static class InstantPojo { + public Instant timestamp; + + public InstantPojo() {} + } + + /** Test POJO with OffsetDateTime field. */ + public static class OffsetDateTimePojo { + public OffsetDateTime timestamp; + + public OffsetDateTimePojo() {} + } + + /** Test POJO with Integer[] field. */ + public static class IntArrayPojo { + public Integer[] items; + + public IntArrayPojo() {} + } + + /** Test POJO with List field. */ + public static class ListPojo { + public List items; + + public ListPojo() {} + } + + /** Test POJO with Map field. */ + public static class MapPojo { + public Map mapping; + + public MapPojo() {} + } + + /** Test POJO with nested POJO field. */ + public static class NestedPojo { + public AllFieldsPojo nested; + + public NestedPojo() {} + } + + /** Test POJO with Enum field. */ + public static class EnumPojo { + public StatusEnum status; + + public EnumPojo() {} + } + + /** Test enum for compatibility testing. */ + public enum StatusEnum { + ACTIVE, + INACTIVE + } +} diff --git a/fluss-client/src/test/java/org/apache/fluss/client/converter/ConvertersTestFixtures.java b/fluss-client/src/test/java/org/apache/fluss/client/converter/ConvertersTestFixtures.java index 0a90c522d3..57279a35d4 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/converter/ConvertersTestFixtures.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/converter/ConvertersTestFixtures.java @@ -57,6 +57,7 @@ public static RowType fullSchema() { .field("offsetDateTimeField", DataTypes.TIMESTAMP_LTZ()) .field("arrayField", DataTypes.ARRAY(DataTypes.INT())) .field("mapField", DataTypes.MAP(DataTypes.STRING(), DataTypes.INT())) + .field("enumField", DataTypes.STRING()) .field("string_with_column_name", DataTypes.STRING()) .build(); } @@ -82,6 +83,7 @@ public static class TestPojo { public OffsetDateTime offsetDateTimeField; public Integer[] arrayField; public Map mapField; + public StatusEnum enumField; @ColumnName("string_with_column_name") public String stringWithColumnName; @@ -106,6 +108,7 @@ public TestPojo( OffsetDateTime offsetDateTimeField, Integer[] arrayField, Map mapField, + StatusEnum enumField, String stringWithColumnName) { this.booleanField = booleanField; this.byteField = byteField; @@ -124,6 +127,7 @@ public TestPojo( this.offsetDateTimeField = offsetDateTimeField; this.arrayField = arrayField; this.mapField = mapField; + this.enumField = enumField; this.stringWithColumnName = stringWithColumnName; } @@ -151,6 +155,7 @@ public static TestPojo sample() { put("test_2", 2); } }, + StatusEnum.OK, "string value"); } @@ -179,6 +184,7 @@ public boolean equals(Object o) { && Objects.equals(timestampLtzField, testPojo.timestampLtzField) && Objects.equals(offsetDateTimeField, testPojo.offsetDateTimeField) && Objects.equals(stringWithColumnName, testPojo.stringWithColumnName) + && Objects.equals(enumField, testPojo.enumField) && Arrays.equals(arrayField, testPojo.arrayField) && Objects.equals(mapField, testPojo.mapField); } @@ -201,6 +207,7 @@ public int hashCode() { timestampField, timestampLtzField, stringWithColumnName, + enumField, offsetDateTimeField, mapField); result = 31 * result + Arrays.hashCode(bytesField); @@ -485,4 +492,10 @@ public static class ListOfRowPojo { public ListOfRowPojo() {} } + + /** Enum to test enum conversion. */ + public enum StatusEnum { + OK, + error + } } diff --git a/fluss-client/src/test/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverterTest.java b/fluss-client/src/test/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverterTest.java new file mode 100644 index 0000000000..438f4101c8 --- /dev/null +++ b/fluss-client/src/test/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverterTest.java @@ -0,0 +1,242 @@ +/* + * 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.fluss.client.converter; + +import org.apache.fluss.row.BinaryString; +import org.apache.fluss.row.TimestampLtz; +import org.apache.fluss.row.TimestampNtz; +import org.apache.fluss.types.DataTypes; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for {@link FlussTypeToPojoTypeConverter}. */ +public class FlussTypeToPojoTypeConverterTest { + + // ==================== convertTextValue Tests ==================== + + @Test + public void testConvertTextValueToString() { + BinaryString binaryStr = BinaryString.fromString("Hello"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "field", String.class, binaryStr); + assertThat(result).isEqualTo("Hello"); + } + + @Test + public void testConvertTextValueToStringFromChar() { + BinaryString binaryStr = BinaryString.fromString("A"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), "field", String.class, binaryStr); + assertThat(result).isEqualTo("A"); + } + + @Test + public void testConvertTextValueToCharacter() { + BinaryString binaryStr = BinaryString.fromString("X"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "field", Character.class, binaryStr); + assertThat(result).isEqualTo('X'); + } + + @Test + public void testConvertTextValueToCharacterFromChar() { + BinaryString binaryStr = BinaryString.fromString("Z"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), "field", Character.class, binaryStr); + assertThat(result).isEqualTo('Z'); + } + + @Test + public void testConvertTextValueNullReturnsNull() { + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "field", String.class, null); + assertThat(result).isNull(); + } + + @Test + public void testConvertTextValueCharWithLengthOneIsValid() { + BinaryString binaryStr = BinaryString.fromString("M"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), "field", String.class, binaryStr); + assertThat(result).isEqualTo("M"); + } + + @Test + public void testConvertTextValueCharWithWrongLengthThrows() { + BinaryString binaryStr = BinaryString.fromString("Hello"); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), "myField", String.class, binaryStr)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueCharacterWithWrongLengthThrows() { + BinaryString binaryStr = BinaryString.fromString("AB"); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), "charField", Character.class, binaryStr)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueEmptyStringToCharacterThrows() { + BinaryString binaryStr = BinaryString.fromString(""); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), + "emptyField", + Character.class, + binaryStr)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueToEnumWithMatchingValue() { + BinaryString binaryStr = BinaryString.fromString("ACTIVE"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "status", StatusEnum.class, binaryStr); + assertThat(result).isEqualTo(StatusEnum.ACTIVE); + } + + @Test + public void testConvertTextValueToEnumWithLowercaseInput() { + BinaryString binaryStr = BinaryString.fromString("finished"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "status", StatusEnum.class, binaryStr); + assertThat(result).isEqualTo(StatusEnum.finished); + } + + @Test + public void testConvertTextValueToEnumWithInvalidValueThrows() { + BinaryString binaryStr = BinaryString.fromString("UNKNOWN"); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "status", StatusEnum.class, binaryStr)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueUnsupportedTypeThrows() { + BinaryString binaryStr = BinaryString.fromString("value"); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "wrongField", Integer.class, binaryStr)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ==================== convertDateValue Tests ==================== + + @Test + public void testConvertDateValueEpoch() { + LocalDate result = FlussTypeToPojoTypeConverter.convertDateValue(0); + assertThat(result).isEqualTo(LocalDate.of(1970, 1, 1)); + } + + @Test + public void testConvertDateValuePositiveOffset() { + LocalDate result = FlussTypeToPojoTypeConverter.convertDateValue(18993); + assertThat(result).isEqualTo(LocalDate.of(2022, 1, 1)); + } + + // ==================== convertTimeValue Tests ==================== + + @Test + public void testConvertTimeValueMidnight() { + LocalTime result = FlussTypeToPojoTypeConverter.convertTimeValue(0); + assertThat(result).isEqualTo(LocalTime.MIDNIGHT); + } + + @Test + public void testConvertTimeValueNoon() { + long millisOfDay = 12 * 60 * 60 * 1000; + LocalTime result = FlussTypeToPojoTypeConverter.convertTimeValue((int) millisOfDay); + assertThat(result).isEqualTo(LocalTime.of(12, 0, 0)); + } + + // ==================== convertTimestampNtzValue Tests ==================== + + @Test + public void testConvertTimestampNtzValue() { + TimestampNtz ts = TimestampNtz.fromLocalDateTime(LocalDateTime.of(2023, 1, 15, 10, 30)); + Object result = FlussTypeToPojoTypeConverter.convertTimestampNtzValue(ts); + assertThat(result).isEqualTo(LocalDateTime.of(2023, 1, 15, 10, 30)); + } + + @Test + public void testConvertTimestampNtzValueWithNanoseconds() { + TimestampNtz ts = TimestampNtz.fromMillis(1000L, 500000); // 1 second + 500000 nanos + Object result = FlussTypeToPojoTypeConverter.convertTimestampNtzValue(ts); + assertThat(result).isInstanceOf(LocalDateTime.class); + LocalDateTime ldt = (LocalDateTime) result; + assertThat(ldt.getNano()).isEqualTo(500000); + } + + // ==================== convertTimestampLtzValue Tests ==================== + + @Test + public void testConvertTimestampLtzValueToInstant() { + TimestampLtz ts = TimestampLtz.fromEpochMillis(1000L); + Object result = + FlussTypeToPojoTypeConverter.convertTimestampLtzValue(ts, "field", Instant.class); + assertThat(result).isEqualTo(Instant.ofEpochMilli(1000L)); + } + + @Test + public void testConvertTimestampLtzValueToOffsetDateTime() { + TimestampLtz ts = TimestampLtz.fromEpochMillis(0L); + Object result = + FlussTypeToPojoTypeConverter.convertTimestampLtzValue( + ts, "field", OffsetDateTime.class); + assertThat(result).isEqualTo(OffsetDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)); + } + + // ==================== Helper Enum ==================== + + /** Test enum for String-to-Enum conversion tests. */ + public enum StatusEnum { + ACTIVE, + INACTIVE, + PENDING, + finished, + } +} diff --git a/fluss-client/src/test/java/org/apache/fluss/client/converter/PojoArrayToFlussArrayTest.java b/fluss-client/src/test/java/org/apache/fluss/client/converter/PojoArrayToFlussArrayTest.java index 2c21e7222c..a1787bd61b 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/converter/PojoArrayToFlussArrayTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/converter/PojoArrayToFlussArrayTest.java @@ -70,6 +70,7 @@ public void testArrayWithAllTypes() { .field( "mapArray", DataTypes.ARRAY(DataTypes.MAP(DataTypes.STRING(), DataTypes.INT()))) + .field("enumArray", DataTypes.ARRAY(DataTypes.STRING())) .build(); PojoToRowConverter writer = PojoToRowConverter.of(ArrayPojo.class, table, table); @@ -242,6 +243,12 @@ public void testArrayWithAllTypes() { assertThat(resultMap2).containsEntry("test_3", 3); assertThat(resultMap2).containsEntry("test_4", 4); + // Verify enum array + InternalArray enumArray = row.getArray(22); + assertThat(enumArray.size()).isEqualTo(3); + assertThat(enumArray.getString(0).toString()).isEqualTo("PENDING"); + assertThat(enumArray.getString(1).toString()).isEqualTo("ACTIVE"); + assertThat(enumArray.getString(2).toString()).isEqualTo("finished"); } @Test @@ -314,6 +321,7 @@ public static class ArrayPojo { public Instant[] timestampLtzArray; public Integer[][] nestedIntArray; public Map[] mapArray; + public StatusEnum[] enumArray; public ArrayPojo() {} @@ -368,6 +376,8 @@ public static ArrayPojo sample() { } } }; + pojo.enumArray = + new StatusEnum[] {StatusEnum.PENDING, StatusEnum.ACTIVE, StatusEnum.finished}; return pojo; } } @@ -434,4 +444,12 @@ public static class SimpleArrayPojo { public SimpleArrayPojo() {} } + + /** Test enum for String-to-Enum conversion tests. */ + public enum StatusEnum { + ACTIVE, + INACTIVE, + PENDING, + finished, + } } diff --git a/fluss-client/src/test/java/org/apache/fluss/client/converter/RowToPojoConverterTest.java b/fluss-client/src/test/java/org/apache/fluss/client/converter/RowToPojoConverterTest.java index 0d849a61ef..ef546f71e9 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/converter/RowToPojoConverterTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/converter/RowToPojoConverterTest.java @@ -44,7 +44,7 @@ public void testRoundTripFullSchema() { ConvertersTestFixtures.TestPojo pojo = ConvertersTestFixtures.TestPojo.sample(); GenericRow row = writer.toRow(pojo); - assertThat(row.getFieldCount()).isEqualTo(18); + assertThat(row.getFieldCount()).isEqualTo(19); ConvertersTestFixtures.TestPojo back = scanner.fromRow(row); assertThat(back).isEqualTo(pojo);