This is an automated email from the ASF dual-hosted git repository.
fresh-borzoni pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git
The following commit(s) were added to refs/heads/main by this push:
new 9dd1a56c4 [client] Add support of column aliasing for PojoConverter
(#3444)
9dd1a56c4 is described below
commit 9dd1a56c439e1bfc0527db7d452244478c82d5fc
Author: Vladyslav Banar <[email protected]>
AuthorDate: Mon Jun 22 16:15:33 2026 +0200
[client] Add support of column aliasing for PojoConverter (#3444)
* [client] Add support of column aliasing for PojoConverter
* [client] test
* [client] updated doc
* [client] fix checks
* [client] address comments
---------
Co-authored-by: valdbanar5 <[email protected]>
---
.../apache/fluss/client/converter/ColumnName.java | 39 ++++++++++++++
.../apache/fluss/client/converter/PojoType.java | 52 ++++++++++++++----
.../client/converter/ConvertersTestFixtures.java | 13 ++++-
.../fluss/client/converter/PojoTypeTest.java | 63 ++++++++++++++++++++++
.../client/converter/RowToPojoConverterTest.java | 2 +-
website/docs/apis/java-client.md | 15 +++---
6 files changed, 165 insertions(+), 19 deletions(-)
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 000000000..f3c040717
--- /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/PojoType.java
b/fluss-client/src/main/java/org/apache/fluss/client/converter/PojoType.java
index 9a60efd98..bf57a3299 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.Locale;
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,59 @@ final class PojoType<T> {
Map<String, Property> props = new LinkedHashMap<>();
for (Map.Entry<String, Field> 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));
}
}
+ if (props.get(mappedColumnName) != null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Duplicated property name '%s' in class %s",
+ mappedColumnName, pojoClass.getName()));
+ }
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 +291,19 @@ final class PojoType<T> {
}
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<String,
AddressPojo>}). */
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 +314,12 @@ final class PojoType<T> {
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/ConvertersTestFixtures.java
b/fluss-client/src/test/java/org/apache/fluss/client/converter/ConvertersTestFixtures.java
index 91d2d11cd..0a90c522d 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 final class ConvertersTestFixtures {
.field("offsetDateTimeField", DataTypes.TIMESTAMP_LTZ())
.field("arrayField", DataTypes.ARRAY(DataTypes.INT()))
.field("mapField", DataTypes.MAP(DataTypes.STRING(),
DataTypes.INT()))
+ .field("string_with_column_name", DataTypes.STRING())
.build();
}
@@ -82,6 +83,9 @@ public final class ConvertersTestFixtures {
public Integer[] arrayField;
public Map<String, Integer> mapField;
+ @ColumnName("string_with_column_name")
+ public String stringWithColumnName;
+
public TestPojo() {}
public TestPojo(
@@ -101,7 +105,8 @@ public final class ConvertersTestFixtures {
Instant timestampLtzField,
OffsetDateTime offsetDateTimeField,
Integer[] arrayField,
- Map<String, Integer> mapField) {
+ Map<String, Integer> mapField,
+ String stringWithColumnName) {
this.booleanField = booleanField;
this.byteField = byteField;
this.shortField = shortField;
@@ -119,6 +124,7 @@ public final class ConvertersTestFixtures {
this.offsetDateTimeField = offsetDateTimeField;
this.arrayField = arrayField;
this.mapField = mapField;
+ this.stringWithColumnName = stringWithColumnName;
}
public static TestPojo sample() {
@@ -144,7 +150,8 @@ public final class ConvertersTestFixtures {
put("test_1", 1);
put("test_2", 2);
}
- });
+ },
+ "string value");
}
@Override
@@ -171,6 +178,7 @@ public final class ConvertersTestFixtures {
&& Objects.equals(timestampField, testPojo.timestampField)
&& Objects.equals(timestampLtzField,
testPojo.timestampLtzField)
&& Objects.equals(offsetDateTimeField,
testPojo.offsetDateTimeField)
+ && Objects.equals(stringWithColumnName,
testPojo.stringWithColumnName)
&& Arrays.equals(arrayField, testPojo.arrayField)
&& Objects.equals(mapField, testPojo.mapField);
}
@@ -192,6 +200,7 @@ public final class ConvertersTestFixtures {
timeField,
timestampField,
timestampLtzField,
+ stringWithColumnName,
offsetDateTimeField,
mapField);
result = 31 * result + Arrays.hashCode(bytesField);
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 e2d44f581..2d261dc50 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 @@ package org.apache.fluss.client.converter;
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,34 @@ class PojoTypeTest {
PojoType.of(PublicWithPublicNonPrimitive.class);
}
+ @Test
+ void testColumnNameAnnotation() {
+ PojoType<PojoWithColumnName> 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");
+ }
+
+ @Test
+ void testColumnNameAnnotationWithDuplicate() {
+ assertThatThrownBy(() ->
PojoType.of(PojoWithDuplicateColumnName.class))
+ .isExactlyInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Duplicated property name");
+ }
+
public class ClassWithNoPublicConstructor {
int f;
int j;
@@ -160,4 +189,38 @@ class PojoTypeTest {
this.b = b;
}
}
+
+ public static class PojoWithColumnName {
+ @ColumnName("user_id")
+ public Long userId;
+
+ @ColumnName("first_name")
+ private String firstName;
+
+ public String email;
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+ }
+
+ public static class PojoWithDuplicateColumnName {
+ @ColumnName("first_name")
+ public String userId;
+
+ @ColumnName("first_name")
+ private String firstName;
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+ }
}
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 92fe60267..0d849a61e 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 class RowToPojoConverterTest {
ConvertersTestFixtures.TestPojo pojo =
ConvertersTestFixtures.TestPojo.sample();
GenericRow row = writer.toRow(pojo);
- assertThat(row.getFieldCount()).isEqualTo(17);
+ assertThat(row.getFieldCount()).isEqualTo(18);
ConvertersTestFixtures.TestPojo back = scanner.fromRow(row);
assertThat(back).isEqualTo(pojo);
diff --git a/website/docs/apis/java-client.md b/website/docs/apis/java-client.md
index eeb169f81..483296b0c 100644
--- a/website/docs/apis/java-client.md
+++ b/website/docs/apis/java-client.md
@@ -336,20 +336,23 @@ The Typed API provides a more user-friendly experience
but comes with a performa
### Defining POJOs
-To use the Typed API, define a Java class where the field names and types
match your Fluss table schema.
+To use the Typed API, define a Java class where the field names and types
match your Fluss table schema. You can use
+@ColumnName to properly map the name.
```java
public class User {
public Integer id;
- public String name;
- public Integer age;
+ @ColumnName("first_name")
+ public String firstName;
+ @ColumnName("last_name")
+ public String lastName;
public User() {}
- public User(Integer id, String name, Integer age) {
+ public User(Integer id, String firstName, String lastName) {
this.id = id;
- this.name = name;
- this.age = age;
+ this.firstName = firstName;
+ this.lastName = lastName;
}
// Getters, setters, equals, hashCode, toString...