Skip to content

Commit

Permalink
#21: EXPERIMENTAL: Field Conversion Improvements (#22)
Browse files Browse the repository at this point in the history
**This PR brings an experimental feature**, explicitly annotated as
`@ExperimentalApi`:

Adding arbitrary converters between Java values and DB-compatible
"column values", using new `@CustomValueType` annotation either on the
type, or inside `@Column` annotation on the entity field.

These "custom value types" can even be used inside IDs, if their
converters transform field value into a ID-compatible value (a String, a
enum, an integer, a timestamp, a boolean value or a byte array).

This feature has the potential to replace all ad-hoc "string-value type"
functionality in the future.
  • Loading branch information
nvamelichev authored Feb 29, 2024
1 parent 4234f06 commit 8d1d38d
Show file tree
Hide file tree
Showing 36 changed files with 821 additions and 247 deletions.
41 changes: 41 additions & 0 deletions databind/src/main/java/tech/ydb/yoj/databind/CustomValueType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package tech.ydb.yoj.databind;

import tech.ydb.yoj.ExperimentalApi;
import tech.ydb.yoj.databind.converter.ValueConverter;
import tech.ydb.yoj.databind.schema.Schema;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.RECORD_COMPONENT;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
* Annotates the class, or entity field/record component as having a custom {@link ValueConverter value converter}.
* <br>The specified converter will be used by YOJ instead of the default (Database column&harr;Java field) mapping.
* <p>Annotation on entity field/record component has priority over annotation on the field's/record component's class.
*/
@Retention(RUNTIME)
@Target({TYPE, FIELD, RECORD_COMPONENT})
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
@SuppressWarnings("rawtypes")
public @interface CustomValueType {
/**
* Simple value type that the {@link #converter()} represents a custom value type as.
* Cannot be {@link FieldValueType#COMPOSITE} or {@link FieldValueType#UNKNOWN}.
*/
FieldValueType columnValueType();

/**
* Exact type of value that {@link #converter() converter's} {@link ValueConverter#toColumn(Schema.JavaField, Object) toColumn()} method returns.
* Must implement {@link Comparable}.
*/
Class<? extends Comparable> columnClass();

/**
* Converter class. Must have a no-args public constructor.
*/
Class<? extends ValueConverter> converter();
}
80 changes: 80 additions & 0 deletions databind/src/main/java/tech/ydb/yoj/databind/CustomValueTypes.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package tech.ydb.yoj.databind;

import com.google.common.base.Preconditions;
import com.google.common.reflect.TypeToken;
import lombok.NonNull;
import tech.ydb.yoj.ExperimentalApi;
import tech.ydb.yoj.databind.converter.ValueConverter;
import tech.ydb.yoj.databind.schema.Column;
import tech.ydb.yoj.databind.schema.CustomConverterException;
import tech.ydb.yoj.databind.schema.Schema.JavaField;

import javax.annotation.Nullable;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Type;

import static java.lang.reflect.Modifier.isAbstract;
import static java.lang.reflect.Modifier.isStatic;

@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
public final class CustomValueTypes {
private CustomValueTypes() {
}

public static Object preconvert(@NonNull JavaField field, Object value) {
var cvt = field.getCustomValueType();
if (cvt != null) {
value = createCustomValueTypeConverter(cvt).toColumn(field, value);

Preconditions.checkArgument(cvt.columnClass().isInstance(value),
"Custom value type converter %s must produce a non-null value of type columnClass()=%s but got value of type %s",
cvt.converter().getCanonicalName(), cvt.columnClass().getCanonicalName(), value.getClass().getCanonicalName());
}
return value;
}

public static Object postconvert(@NonNull JavaField field, Object value) {
var cvt = field.getCustomValueType();
if (cvt != null) {
value = createCustomValueTypeConverter(cvt).toJava(field, value);
}
return value;
}

// TODO: Add caching to e.g. SchemaRegistry using @CustomValueType+[optionally JavaField if there is @Column annotation]+[type] as key,
// to avoid repetitive construction of ValueConverters
private static <V, C> ValueConverter<V, C> createCustomValueTypeConverter(CustomValueType cvt) {
try {
var ctor = cvt.converter().getDeclaredConstructor();
ctor.setAccessible(true);
@SuppressWarnings("unchecked") var converter = (ValueConverter<V, C>) ctor.newInstance();
return converter;
} catch (InstantiationException | IllegalAccessException | NoSuchMethodException | SecurityException | InvocationTargetException e) {
throw new CustomConverterException(e, "Could not return custom value type converter " + cvt.converter());
}
}

@Nullable
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
public static CustomValueType getCustomValueType(@NonNull Type type, @Nullable Column columnAnnotation) {
var rawType = type instanceof Class<?> ? (Class<?>) type : TypeToken.of(type).getRawType();

var cvtAnnotation = columnAnnotation == null ? null : columnAnnotation.customValueType();

var columnCvt = cvtAnnotation == null || cvtAnnotation.converter().equals(ValueConverter.NoConverter.class) ? null : cvtAnnotation;
var cvt = columnCvt == null ? rawType.getAnnotation(CustomValueType.class) : columnCvt;
if (cvt != null) {
Preconditions.checkArgument(!cvt.columnValueType().isComposite(), "@CustomValueType.columnValueType must be != COMPOSITE");
Preconditions.checkArgument(!cvt.columnValueType().isUnknown(), "@CustomValueType.columnValueType must be != UNKNOWN");
Preconditions.checkArgument(
!cvt.converter().equals(ValueConverter.NoConverter.class)
&& !cvt.converter().isInterface()
&& !isAbstract(cvt.converter().getModifiers())
&& (cvt.converter().getDeclaringClass() == null || isStatic(cvt.converter().getModifiers())),
"@CustomValueType.converter must not be an interface, abstract class, non-static inner class, or NoConverter.class, but got: %s",
cvt);
}

return cvt;
}
}
56 changes: 44 additions & 12 deletions databind/src/main/java/tech/ydb/yoj/databind/FieldValueType.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.google.common.base.Preconditions;
import lombok.NonNull;
import tech.ydb.yoj.ExperimentalApi;
import tech.ydb.yoj.databind.converter.StringValueConverter;
import tech.ydb.yoj.databind.schema.Column;

import java.lang.reflect.ParameterizedType;
Expand Down Expand Up @@ -60,7 +62,11 @@ public enum FieldValueType {
/**
* Binary value: just a stream of uninterpreted bytes.
* Java-side <strong>must</strong> be a byte array.
* <p>
* @deprecated It is strongly recommended to use a {@link ByteArray} that is properly {@code Comparable}
* and has a sane {@code equals()}.
*/
@Deprecated
BINARY,
/**
* Binary value: just a stream of uninterpreted bytes.
Expand Down Expand Up @@ -100,6 +106,20 @@ public enum FieldValueType {
String.class
));

/**
* @deprecated It is recommended to use the {@link CustomValueType} annotation with a {@link StringValueConverter}
* instead of calling this method.
* <p>
* To register a class <em>not in your code</em> (e.g., {@code UUID} from the JDK) as a string-value type, use
* a {@link Column &#64;Column(customValueType=&#64;CustomValueType(...))} annotation on the specific field.
* <p>
* Future versions of YOJ might remove this method entirely.
*
* @param clazz class to register as string-value. Must either be final or sealed with permissible final-only implementations.
* All permissible implementations of a sealed class will be registered automatically.
*/
@Deprecated(forRemoval = true)
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
public static void registerStringValueType(@NonNull Class<?> clazz) {
boolean isFinal = isFinal(clazz.getModifiers());
boolean isSealed = clazz.isSealed();
Expand All @@ -116,32 +136,34 @@ public static void registerStringValueType(@NonNull Class<?> clazz) {
* Detects database field type appropriate for a Java object of type {@code type}.
*
* @param type Java object type
* @param columnAnnotation {@code @Column} annotation for the field
* @param columnAnnotation {@code @Column} annotation for the field; {@code null} if absent
*
* @return database value type
* @throws IllegalArgumentException if object of this type cannot be mapped to a database value
*/
@NonNull
public static FieldValueType forJavaType(Type type, Column columnAnnotation) {
var cvt = CustomValueTypes.getCustomValueType(type, columnAnnotation);
if (cvt != null) {
return cvt.columnValueType();
}

boolean flatten = columnAnnotation == null || columnAnnotation.flatten();
FieldValueType valueType = forJavaType(type);
return valueType.isComposite() && !flatten ? FieldValueType.OBJECT : valueType;
}

/**
* Detects database field type appropriate for a Java object of type {@code type}.
*
* @param type Java object type
* @return database value type
* @throws IllegalArgumentException if object of this type cannot be mapped to a database value
*/
@NonNull
public static FieldValueType forJavaType(@NonNull Type type) {
private static FieldValueType forJavaType(@NonNull Type type) {
if (type instanceof ParameterizedType || type instanceof TypeVariable) {
return OBJECT;
} else if (type instanceof Class<?> clazz) {
// FIXME: remove static configuration here, more it to e.g. a class annotation (or SchemaRegistry)
if (STRING_VALUE_TYPES.contains(clazz)) {
var cvt = CustomValueTypes.getCustomValueType(clazz, null);
if (cvt != null) {
return cvt.columnValueType();
}

if (isStringValueType(clazz)) {
return STRING;
} else if (INTEGER_NUMERIC_TYPES.contains(clazz)) {
return INTEGER;
Expand Down Expand Up @@ -176,17 +198,27 @@ public static FieldValueType forJavaType(@NonNull Type type) {
}
}

private static boolean isStringValueType(Class<?> clazz) {
return STRING_VALUE_TYPES.contains(clazz);
}

/**
* Checks whether Java object of type {@code type} is mapped to a composite database value
* (i.e. > 1 database field)
*
* @deprecated This method does not properly take into account the customizations specified in the
* {@link Column &#64;Column} annotation on the field. Please do not call it directly, instead use
* {@code FieldValueType.forJavaType(type, column).isComposite()} where {@code column} is the
* {@link Column &#64;Column} annotation's value.
*
* @param type Java object type
* @return {@code true} if {@code type} maps to a composite database value; {@code false} otherwise
* @throws IllegalArgumentException if object of this type cannot be mapped to a database value
* @see #isComposite()
*/
@Deprecated(forRemoval = true)
public static boolean isComposite(@NonNull Type type) {
return forJavaType(type).isComposite();
return forJavaType(type, null).isComposite();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package tech.ydb.yoj.databind.converter;

import lombok.NonNull;
import lombok.SneakyThrows;
import tech.ydb.yoj.databind.schema.Schema.JavaField;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.function.Function;

import static java.lang.String.format;
import static java.lang.reflect.Modifier.isStatic;

/**
* Possible String value type replacement: a generic converter that can be applied to your
* type/your columns with
* <blockquote>
* <pre>
* &#64;Column(
* customValueType=&#64;CustomValueType(
* columnValueType=STRING,
* columnClass=&lt;your type&gt;,
* converter=StringValueConverter.class
* )
* )
* </pre>
* </blockquote>
*
* @param <J> Java type
*/
public final class StringValueConverter<J> implements ValueConverter<J, String> {
private volatile Function<String, J> deserializer;

private StringValueConverter() {
}

@Override
public @NonNull String toColumn(@NonNull JavaField field, @NonNull J value) {
return value.toString();
}

@Override
@SuppressWarnings("unchecked")
public @NonNull J toJava(@NonNull JavaField field, @NonNull String column) {
var clazz = field.getRawType();
if (String.class.equals(clazz)) {
return (J) column;
}

var f = deserializer;
if (deserializer == null) {
synchronized (this) {
f = deserializer;
if (f == null) {
deserializer = f = getStringValueDeserializerMethod(clazz);
}
}
}
return f.apply(column);
}

@SuppressWarnings("unchecked")
private static <J> ThrowingFunction<String, J> getStringValueDeserializerMethod(Class<?> clazz) {
for (String methodName : new String[]{"fromString", "valueOf"}) {
try {
Method method = clazz.getMethod(methodName, String.class);
if (isStatic(method.getModifiers())) {
return s -> (J) method.invoke(null, s);
}
} catch (NoSuchMethodException ignored) {
}
}

try {
var ctor = clazz.getConstructor(String.class);
return s -> (J) ctor.newInstance(s);
} catch (NoSuchMethodException ignored) {
}

throw new IllegalArgumentException(format(
"Type <%s> does not have a deserializer method: public static fromString(String)/valueOf(String) and" +
"doesn't have constructor public %s(String)",
clazz.getTypeName(),
clazz.getTypeName()
));
}

private interface ThrowingFunction<T, R> extends Function<T, R> {
R applyThrowing(T t) throws Exception;

@Override
@SneakyThrows
default R apply(T t) {
try {
return applyThrowing(t);
} catch (InvocationTargetException e) {
// Propagate the real exception thrown by the deserializer method.
// All unhandled getter exceptions are wrapped in ConversionException by the Repository, automatically,
// so we don't need to do any wrapping here.
throw e.getCause();
} catch (Exception e) {
throw new IllegalStateException("Reflection problem with deserializer method", e);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package tech.ydb.yoj.databind.converter;

import lombok.NonNull;
import tech.ydb.yoj.ExperimentalApi;
import tech.ydb.yoj.databind.schema.Schema.JavaField;

/**
* Custom conversion logic between database column values and Java field values.
* <br><strong>Must</strong> have a no-args public constructor.
*
* @param <J> Java value type
* @param <C> Database column value type
*/
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
public interface ValueConverter<J, C> {
@NonNull
C toColumn(@NonNull JavaField field, @NonNull J v);

@NonNull
J toJava(@NonNull JavaField field, @NonNull C c);

class NoConverter implements ValueConverter<Void, Void> {
private NoConverter() {
throw new UnsupportedOperationException("Not instantiable");
}

@Override
public @NonNull Void toColumn(@NonNull JavaField field, @NonNull Void v) {
throw new UnsupportedOperationException();
}

@Override
public @NonNull Void toJava(@NonNull JavaField field, @NonNull Void unused) {
throw new UnsupportedOperationException();
}
}
}
Loading

0 comments on commit 8d1d38d

Please sign in to comment.