diff --git a/fluss-client/src/main/java/org/apache/fluss/client/converter/ColumnName.java b/fluss-client/src/main/java/org/apache/fluss/client/converter/ColumnName.java new file mode 100644 index 0000000000..f3c040717f --- /dev/null +++ b/fluss-client/src/main/java/org/apache/fluss/client/converter/ColumnName.java @@ -0,0 +1,39 @@ +/* + * 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 java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify a custom table column name for a POJO field. When present, the converter + * will map between the POJO field and the table column using the specified name. + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ColumnName { + /** + * The name of the table column that this field maps to. + * + * @return the column name in the Fluss table + */ + String value(); +} 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..43433ecec2 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,6 +31,7 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Arrays; /** Shared utilities for Fluss type and Pojo type. */ public class FlussTypeToPojoTypeConverter { @@ -74,6 +75,17 @@ static Object convertTextValue( ConverterCommons.charLengthExceptionMessage(fieldName, v.length())); } return v.charAt(0); + } else if (pojoType.isEnum()) { + return Arrays.stream(pojoType.getEnumConstants()) + .filter(e -> e.toString().equals(v.toUpperCase())) + .findAny() + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Could not parse value for enum %s. Expected one of: [%s]", + pojoType, + Arrays.toString(pojoType.getEnumConstants())))); } 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 9a60efd98f..5cd27f47ec 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 @@ -30,6 +30,8 @@ import java.util.Map; import java.util.Objects; +import static org.apache.fluss.utils.Preconditions.checkArgument; + /** * Internal representation of a POJO type, used to validate POJO requirements and to provide unified * accessors for reading/writing properties. @@ -73,39 +75,53 @@ static PojoType of(Class pojoClass) { Map props = new LinkedHashMap<>(); for (Map.Entry e : allFields.entrySet()) { - String name = e.getKey(); + String fieldName = e.getKey(); Field field = e.getValue(); // Enforce nullable fields: primitives are not allowed in POJO definitions. if (field.getType().isPrimitive()) { throw new IllegalArgumentException( String.format( "POJO class %s has primitive field '%s' of type %s. Primitive types are not allowed; all fields must be nullable (use wrapper types).", - pojoClass.getName(), name, field.getType().getName())); + pojoClass.getName(), fieldName, field.getType().getName())); } + // Check for @ColumnName annotation to determine the mapped column name + ColumnName columnNameAnnotation = field.getAnnotation(ColumnName.class); + String mappedColumnName = + columnNameAnnotation != null ? columnNameAnnotation.value() : fieldName; + checkArgument( + !mappedColumnName.isEmpty(), + "Column name cannot be empty for field '%s' in POJO class %s", + fieldName, + pojoClass.getName()); + // use boxed type as effective type Class effectiveType = boxIfPrimitive(field.getType()); boolean publicField = Modifier.isPublic(field.getModifiers()); - Method getter = getters.get(name); - Method setter = setters.get(name); + Method getter = getters.get(fieldName); + Method setter = setters.get(fieldName); if (!publicField) { // When not a public field, require both getter and setter if (getter == null || setter == null) { - final String capitalizedName = capitalize(name); + final String capitalizedName = capitalize(fieldName); throw new IllegalArgumentException( String.format( "POJO class %s field '%s' must be public or have both getter and setter (get%s/set%s).", - pojoClass.getName(), name, capitalizedName, capitalizedName)); + pojoClass.getName(), + fieldName, + capitalizedName, + capitalizedName)); } } props.put( - name, + mappedColumnName, new Property( - name, + fieldName, effectiveType, field.getGenericType(), publicField ? field : null, getter, - setter)); + setter, + mappedColumnName)); } return new PojoType<>(pojoClass, ctor, props); @@ -269,11 +285,19 @@ private static Class boxIfPrimitive(Class type) { } static final class Property { + /** The name of the field in the POJO class (e.g. "userId"). */ final String name; + final Class type; /** 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; @@ -284,10 +308,12 @@ static final class Property { Type genericType, @Nullable Field publicField, @Nullable Method getter, - @Nullable Method setter) { + @Nullable Method setter, + String mappedName) { 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/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/FlussTypeToPojoTypeConverterTest.java b/fluss-client/src/test/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverterTest.java new file mode 100644 index 0000000000..86f28377bd --- /dev/null +++ b/fluss-client/src/test/java/org/apache/fluss/client/converter/FlussTypeToPojoTypeConverterTest.java @@ -0,0 +1,250 @@ +/* + * 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("inactive"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "status", StatusEnum.class, binaryStr); + assertThat(result).isEqualTo(StatusEnum.INACTIVE); + } + + @Test + public void testConvertTextValueToEnumWithMixedCaseInput() { + BinaryString binaryStr = BinaryString.fromString("PeNdInG"); + Object result = + FlussTypeToPojoTypeConverter.convertTextValue( + DataTypes.STRING(), "status", StatusEnum.class, binaryStr); + assertThat(result).isEqualTo(StatusEnum.PENDING); + } + + @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 + } +} 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 e2d44f5814..37ddd0efcf 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 @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** Basic tests for {@link PojoType}. */ @@ -61,6 +62,27 @@ void test() { PojoType.of(PublicWithPublicNonPrimitive.class); } + @Test + void testColumnNameAnnotation() { + PojoType pojoType = PojoType.of(PojoWithColumnName.class); + + assertThat(pojoType.getProperty("user_id")) + .isNotNull() + .hasFieldOrPropertyWithValue("name", "userId") + .hasFieldOrPropertyWithValue("mappedName", "user_id"); + + assertThat(pojoType.getProperty("first_name")) + .isNotNull() + .hasFieldOrPropertyWithValue("name", "firstName") + .hasFieldOrPropertyWithValue("mappedName", "first_name"); + + // Fields without @ColumnName should map to themselves + assertThat(pojoType.getProperty("email")) + .isNotNull() + .hasFieldOrPropertyWithValue("name", "email") + .hasFieldOrPropertyWithValue("mappedName", "email"); + } + public class ClassWithNoPublicConstructor { int f; int j; @@ -160,4 +182,44 @@ public void setB(boolean b) { this.b = b; } } + + public static class PojoWithColumnName { + @ColumnName("user_id") + public Long userId; + + @ColumnName("first_name") + private String firstName; + + public String email; + + public PojoWithColumnName() {} + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + } + + public static class PojoWithMixedColumnNames { + @ColumnName("phone_number") + public String phoneNumber; + + @ColumnName("last_name") + private String lastName; + + public Boolean active; + + public PojoWithMixedColumnNames() {} + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + } }