-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
4234f06
commit 8d1d38d
Showing
36 changed files
with
821 additions
and
247 deletions.
There are no files selected for viewing
41 changes: 41 additions & 0 deletions
41
databind/src/main/java/tech/ydb/yoj/databind/CustomValueType.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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↔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
80
databind/src/main/java/tech/ydb/yoj/databind/CustomValueTypes.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
106 changes: 106 additions & 0 deletions
106
databind/src/main/java/tech/ydb/yoj/databind/converter/StringValueConverter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
* @Column( | ||
* customValueType=@CustomValueType( | ||
* columnValueType=STRING, | ||
* columnClass=<your type>, | ||
* 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); | ||
} | ||
} | ||
} | ||
} |
37 changes: 37 additions & 0 deletions
37
databind/src/main/java/tech/ydb/yoj/databind/converter/ValueConverter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
} |
Oops, something went wrong.