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..5b21127ed7 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 column, 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/FlussArrayToPojoArray.java b/fluss-client/src/main/java/org/apache/fluss/client/converter/FlussArrayToPojoArray.java index 59bf264208..fb4e8f8eda 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/converter/FlussArrayToPojoArray.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/converter/FlussArrayToPojoArray.java @@ -32,6 +32,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** Adapter class for converting Fluss InternalArray to Pojo Array. */ public class FlussArrayToPojoArray { @@ -115,9 +116,17 @@ private static ElementConverter buildElementConverter( case STRING: // Default to String when the POJO element type is unspecified (Object[]) final Class textTarget = (pojoType == Object.class) ? String.class : pojoType; + final Map enumConstantsMap = + textTarget.isEnum() + ? FlussTypeToPojoTypeConverter.buildEnumConstantsMap(textTarget) + : null; return (arr, i) -> FlussTypeToPojoTypeConverter.convertTextValue( - elementType, fieldName, textTarget, arr.getString(i)); + elementType, + fieldName, + textTarget, + arr.getString(i), + enumConstantsMap); case BINARY: case BYTES: return InternalArray::getBytes; 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..6fb09985fb 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,28 @@ 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; /** Shared utilities for Fluss type and Pojo type. */ public class FlussTypeToPojoTypeConverter { + + /** + * Builds a map of enum name to enum constant for the given enum class. + * + * @param enumClass the enum class + * @return an immutable map from enum constant name to enum constant + */ + static Map buildEnumConstantsMap(Class enumClass) { + Map map = new HashMap<>(); + for (Object constant : enumClass.getEnumConstants()) { + map.put(((Enum) constant).name(), 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. @@ -45,12 +64,18 @@ public class FlussTypeToPojoTypeConverter { * @param fieldName The field name * @param pojoType The pojo type * @param s The BinaryString read from the row + * @param enumConstantsMap (optional) Pre-built enum constants map for enum types; ignored for + * non-enum types * @return Converted Java value (String or Character) * @throws IllegalArgumentException if the target type is unsupported or constraints are * violated */ static Object convertTextValue( - DataType fieldType, String fieldName, Class pojoType, BinaryString s) { + DataType fieldType, + String fieldName, + Class pojoType, + BinaryString s, + Map enumConstantsMap) { if (s == null) { return null; } @@ -74,6 +99,17 @@ static Object convertTextValue( ConverterCommons.charLengthExceptionMessage(fieldName, v.length())); } return v.charAt(0); + } else if (pojoType.isEnum()) { + Map enumMap = + enumConstantsMap != null ? enumConstantsMap : buildEnumConstantsMap(pojoType); + 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/main/java/org/apache/fluss/client/converter/PojoType.java b/fluss-client/src/main/java/org/apache/fluss/client/converter/PojoType.java index bf57a32994..a81f523d81 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/converter/PojoType.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/converter/PojoType.java @@ -126,8 +126,7 @@ static PojoType of(Class pojoClass) { field.getGenericType(), publicField ? field : null, getter, - setter, - mappedColumnName)); + setter)); } return new PojoType<>(pojoClass, ctor, props); @@ -298,12 +297,6 @@ static final class Property { /** The generic type of the field (e.g. {@code Map}). */ final Type genericType; - /** - * The name of the column in the Fluss table. This may differ from 'name' if a @ColumnName - * annotation is present. Used for looking up the property by table column name. - */ - final String mappedName; - @Nullable final Field publicField; @Nullable final Method getter; @Nullable final Method setter; @@ -314,12 +307,10 @@ static final class Property { Type genericType, @Nullable Field publicField, @Nullable Method getter, - @Nullable Method setter, - String mappedName) { + @Nullable Method setter) { this.name = Objects.requireNonNull(name, "name"); this.type = Objects.requireNonNull(type, "type"); this.genericType = Objects.requireNonNull(genericType, "genericType"); - this.mappedName = Objects.requireNonNull(mappedName, "mappedName"); this.publicField = publicField; this.getter = getter; this.setter = setter; diff --git a/fluss-client/src/main/java/org/apache/fluss/client/converter/RowToPojoConverter.java b/fluss-client/src/main/java/org/apache/fluss/client/converter/RowToPojoConverter.java index a54b8d97d9..3897f5057f 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/converter/RowToPojoConverter.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/converter/RowToPojoConverter.java @@ -141,9 +141,19 @@ private static RowToField createRowReader(DataType fieldType, PojoType.Property return InternalRow::getDouble; case CHAR: case STRING: - return (row, pos) -> - FlussTypeToPojoTypeConverter.convertTextValue( - fieldType, prop.name, prop.type, row.getString(pos)); + { + final Map enumConstantsMap = + prop.type.isEnum() + ? FlussTypeToPojoTypeConverter.buildEnumConstantsMap(prop.type) + : null; + return (row, pos) -> + FlussTypeToPojoTypeConverter.convertTextValue( + fieldType, + prop.name, + prop.type, + row.getString(pos), + enumConstantsMap); + } case BINARY: case BYTES: return InternalRow::getBytes; 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..b7d48a1237 --- /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 column"); + } + + @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..104c1ebe40 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,7 +57,11 @@ 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()) + .field("enumList", DataTypes.ARRAY(DataTypes.STRING())) + .field("enumValueMap", DataTypes.MAP(DataTypes.STRING(), DataTypes.STRING())) + .field("enumKeyMap", DataTypes.MAP(DataTypes.STRING(), DataTypes.STRING())) .build(); } @@ -82,10 +86,15 @@ public static class TestPojo { public OffsetDateTime offsetDateTimeField; public Integer[] arrayField; public Map mapField; + public StatusEnum enumField; @ColumnName("string_with_column_name") public String stringWithColumnName; + public List enumList; + public Map enumValueMap; + public Map enumKeyMap; + public TestPojo() {} public TestPojo( @@ -106,7 +115,11 @@ public TestPojo( OffsetDateTime offsetDateTimeField, Integer[] arrayField, Map mapField, - String stringWithColumnName) { + StatusEnum enumField, + String stringWithColumnName, + List enumList, + Map enumValueMap, + Map enumKeyMap) { this.booleanField = booleanField; this.byteField = byteField; this.shortField = shortField; @@ -124,7 +137,11 @@ public TestPojo( this.offsetDateTimeField = offsetDateTimeField; this.arrayField = arrayField; this.mapField = mapField; + this.enumField = enumField; this.stringWithColumnName = stringWithColumnName; + this.enumList = enumList; + this.enumValueMap = enumValueMap; + this.enumKeyMap = enumKeyMap; } public static TestPojo sample() { @@ -151,7 +168,11 @@ public static TestPojo sample() { put("test_2", 2); } }, - "string value"); + StatusEnum.OK, + "string value", + List.of(StatusEnum.error, StatusEnum.OK), + Map.of("key1", StatusEnum.OK, "key2", StatusEnum.error), + Map.of(StatusEnum.OK, "value1", StatusEnum.error, "value2")); } @Override @@ -179,8 +200,12 @@ 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); + && Objects.equals(mapField, testPojo.mapField) + && Objects.equals(enumList, testPojo.enumList) + && Objects.equals(enumKeyMap, testPojo.enumKeyMap) + && Objects.equals(enumValueMap, testPojo.enumValueMap); } @Override @@ -201,7 +226,11 @@ public int hashCode() { timestampField, timestampLtzField, stringWithColumnName, + enumField, offsetDateTimeField, + enumList, + enumKeyMap, + enumValueMap, mapField); result = 31 * result + Arrays.hashCode(bytesField); result = 31 * result + Arrays.hashCode(arrayField); @@ -485,4 +514,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..2afcbe02ec --- /dev/null +++ b/fluss-client/src/test/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverterTest.java @@ -0,0 +1,281 @@ +/* + * 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 java.util.Map; + +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, null); + assertThat(result).isEqualTo("Hello"); + } + + @Test + public void testConvertTextValueToStringFromChar() { + BinaryString binaryStr = BinaryString.fromString("A"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), "field", String.class, binaryStr, null); + assertThat(result).isEqualTo("A"); + } + + @Test + public void testConvertTextValueToCharacter() { + BinaryString binaryStr = BinaryString.fromString("X"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "field", Character.class, binaryStr, null); + assertThat(result).isEqualTo('X'); + } + + @Test + public void testConvertTextValueToCharacterFromChar() { + BinaryString binaryStr = BinaryString.fromString("Z"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), "field", Character.class, binaryStr, null); + assertThat(result).isEqualTo('Z'); + } + + @Test + public void testConvertTextValueNullReturnsNull() { + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "field", String.class, null, null); + assertThat(result).isNull(); + } + + @Test + public void testConvertTextValueCharWithLengthOneIsValid() { + BinaryString binaryStr = BinaryString.fromString("M"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), "field", String.class, binaryStr, null); + assertThat(result).isEqualTo("M"); + } + + @Test + public void testConvertTextValueCharWithWrongLengthThrows() { + BinaryString binaryStr = BinaryString.fromString("Hello"); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), + "myField", + String.class, + binaryStr, + null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueCharacterWithWrongLengthThrows() { + BinaryString binaryStr = BinaryString.fromString("AB"); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.CHAR(1), + "charField", + Character.class, + binaryStr, + null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueEmptyStringToCharacterThrows() { + BinaryString binaryStr = BinaryString.fromString(""); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), + "emptyField", + Character.class, + binaryStr, + null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueToEnumWithMatchingValue() { + BinaryString binaryStr = BinaryString.fromString("ACTIVE"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), + "status", + StatusEnum.class, + binaryStr, + buildConstantMap()); + assertThat(result).isEqualTo(StatusEnum.ACTIVE); + } + + @Test + public void testConvertTextValueToEnumWithLowercaseInput() { + BinaryString binaryStr = BinaryString.fromString("finished"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), + "status", + StatusEnum.class, + binaryStr, + buildConstantMap()); + assertThat(result).isEqualTo(StatusEnum.finished); + } + + @Test + public void testConvertTextValueToEnumWithInvalidValueThrows() { + BinaryString binaryStr = BinaryString.fromString("UNKNOWN"); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), + "status", + StatusEnum.class, + binaryStr, + buildConstantMap())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueUnsupportedTypeThrows() { + BinaryString binaryStr = BinaryString.fromString("value"); + assertThatThrownBy( + () -> + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), + "wrongField", + Integer.class, + binaryStr, + buildConstantMap())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testConvertTextValueToEnumWithMatchingValueAndWithoutConstantMap() { + BinaryString binaryStr = BinaryString.fromString("ACTIVE"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "status", StatusEnum.class, binaryStr, null); + assertThat(result).isEqualTo(StatusEnum.ACTIVE); + } + + // ==================== 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)); + } + + private static Map buildConstantMap() { + return FlussTypeToPojoTypeConverter.buildEnumConstantsMap(StatusEnum.class); + } + + // ==================== 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/PojoTypeTest.java b/fluss-client/src/test/java/org/apache/fluss/client/converter/PojoTypeTest.java index 2d261dc50a..1e70a7ed93 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/converter/PojoTypeTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/converter/PojoTypeTest.java @@ -68,19 +68,16 @@ void testColumnNameAnnotation() { assertThat(pojoType.getProperty("user_id")) .isNotNull() - .hasFieldOrPropertyWithValue("name", "userId") - .hasFieldOrPropertyWithValue("mappedName", "user_id"); + .hasFieldOrPropertyWithValue("name", "userId"); assertThat(pojoType.getProperty("first_name")) .isNotNull() - .hasFieldOrPropertyWithValue("name", "firstName") - .hasFieldOrPropertyWithValue("mappedName", "first_name"); + .hasFieldOrPropertyWithValue("name", "firstName"); // Fields without @ColumnName should map to themselves assertThat(pojoType.getProperty("email")) .isNotNull() - .hasFieldOrPropertyWithValue("name", "email") - .hasFieldOrPropertyWithValue("mappedName", "email"); + .hasFieldOrPropertyWithValue("name", "email"); } @Test 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..cf09ab031d 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,26 @@ public void testRoundTripFullSchema() { ConvertersTestFixtures.TestPojo pojo = ConvertersTestFixtures.TestPojo.sample(); GenericRow row = writer.toRow(pojo); - assertThat(row.getFieldCount()).isEqualTo(18); + assertThat(row.getFieldCount()).isEqualTo(22); + + ConvertersTestFixtures.TestPojo back = scanner.fromRow(row); + assertThat(back).isEqualTo(pojo); + } + + @Test + public void testRoundTripFullSchemaWithLowerCaseEnum() { + RowType table = ConvertersTestFixtures.fullSchema(); + RowType projection = table; + + PojoToRowConverter writer = + PojoToRowConverter.of(ConvertersTestFixtures.TestPojo.class, table, projection); + RowToPojoConverter scanner = + RowToPojoConverter.of(ConvertersTestFixtures.TestPojo.class, table, projection); + + ConvertersTestFixtures.TestPojo pojo = ConvertersTestFixtures.TestPojo.sample(); + pojo.enumField = ConvertersTestFixtures.StatusEnum.error; + GenericRow row = writer.toRow(pojo); + assertThat(row.getFieldCount()).isEqualTo(22); ConvertersTestFixtures.TestPojo back = scanner.fromRow(row); assertThat(back).isEqualTo(pojo);