Skip to content

Commit

Permalink
Better @CustomValueType that is applicable at column level
Browse files Browse the repository at this point in the history
  • Loading branch information
nvamelichev committed Feb 23, 2024
1 parent dcb8e30 commit f6c3f86
Show file tree
Hide file tree
Showing 27 changed files with 334 additions and 133 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@
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;

@Target(TYPE)
/**
* 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")
public @interface CustomValueType {
/**
Expand Down
75 changes: 75 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,75 @@
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.schema.Column;
import tech.ydb.yoj.databind.schema.CustomConverterException;

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(@Nullable CustomValueType cvt, Object value) {
if (cvt != null) {
value = createCustomValueTypeConverter(cvt).toColumn(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(@Nullable CustomValueType cvt, Object value) {
if (cvt != null) {
value = createCustomValueTypeConverter(cvt).toJava(value);
}
return value;
}

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;
}
}
25 changes: 14 additions & 11 deletions databind/src/main/java/tech/ydb/yoj/databind/FieldValueType.java
Original file line number Diff line number Diff line change
Expand Up @@ -136,31 +136,29 @@ private static void ensureValidStringValueType(@NotNull 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) {
var cvt = clazz.getAnnotation(CustomValueType.class);
var cvt = CustomValueTypes.getCustomValueType(clazz, null);
if (cvt != null) {
return cvt.columnValueType();
}
Expand Down Expand Up @@ -216,13 +214,18 @@ private static boolean isStringValueType(Class<?> 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.of(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()
*/
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
Expand Up @@ -11,15 +11,15 @@

/**
* Marks the type as a <em>String-value type</em>, serialized to the database as text by calling {@code toString()} and
* deserialized back using {@code static [TYPE] fromString(String)} or {@code static [TYPE] valueOf(String)} method.
* deserialized back using {@code static [TYPE] fromString(String)} method, {@code static [TYPE] valueOf(String)} method,
* or {@code [TYPE](String)} constructor.
* <p>
* In general, we recommend the more versatile {@link CustomValueType &#64;CustomValueType} annotation as it allows for
* fully custom conversion logic.
*/
@Target(TYPE)
@Retention(RUNTIME)
@Inherited
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/21")
public @interface StringValueType {
/**
* Experimental feature: Represent the whole Entity ID as a String.
*/
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/21")
boolean entityId();
}
27 changes: 22 additions & 5 deletions databind/src/main/java/tech/ydb/yoj/databind/ValueConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,33 @@
import tech.ydb.yoj.ExperimentalApi;

/**
* Custom value conversion logic. Must have a no-args public constructor.
* Custom conversion logic between database column values and Java field values.
* <br><strong>Must</strong> have a no-args public constructor.
*
* @param <V> Java value type
* @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<V, C> {
public interface ValueConverter<J, C> {
@NonNull
C toColumn(@NonNull V v);
C toColumn(@NonNull J v);

@NonNull
V toJava(@NonNull C c);
J toJava(@NonNull C c);

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

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

@Override
public @NonNull Void toJava(@NonNull Void unused) {
throw new UnsupportedOperationException();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import lombok.RequiredArgsConstructor;
import lombok.Value;
import tech.ydb.yoj.databind.ByteArray;
import tech.ydb.yoj.databind.CustomValueType;
import tech.ydb.yoj.databind.CustomValueTypes;
import tech.ydb.yoj.databind.FieldValueType;
import tech.ydb.yoj.databind.schema.Column;
import tech.ydb.yoj.databind.schema.ObjectSchema;
import tech.ydb.yoj.databind.schema.Schema.JavaField;
import tech.ydb.yoj.databind.schema.Schema.JavaFieldValue;
Expand Down Expand Up @@ -38,44 +41,49 @@ public class FieldValue {
ByteArray byteArray;

@NonNull
public static FieldValue ofStr(@NonNull String str) {
private static FieldValue ofStr(@NonNull String str) {
return new FieldValue(str, null, null, null, null, null, null);
}

@NonNull
public static FieldValue ofNum(long num) {
private static FieldValue ofNum(long num) {
return new FieldValue(null, num, null, null, null, null, null);
}

@NonNull
public static FieldValue ofReal(double real) {
private static FieldValue ofReal(double real) {
return new FieldValue(null, null, real, null, null, null, null);
}

@NonNull
public static FieldValue ofBool(boolean bool) {
private static FieldValue ofBool(boolean bool) {
return new FieldValue(null, null, null, bool, null, null, null);
}

@NonNull
public static FieldValue ofTimestamp(@NonNull Instant timestamp) {
private static FieldValue ofTimestamp(@NonNull Instant timestamp) {
return new FieldValue(null, null, null, null, timestamp, null, null);
}

@NonNull
public static FieldValue ofTuple(@NonNull Tuple tuple) {
private static FieldValue ofTuple(@NonNull Tuple tuple) {
return new FieldValue(null, null, null, null, null, tuple, null);
}

@NonNull
public static FieldValue ofByteArray(@NonNull ByteArray byteArray) {
private static FieldValue ofByteArray(@NonNull ByteArray byteArray) {
return new FieldValue(null, null, null, null, null, null, byteArray);
}

@NonNull
public static FieldValue ofObj(@NonNull Object obj, @NonNull JavaField jf) {
return ofObj(obj, jf.getField().getColumn());
}

@NonNull
@SuppressWarnings({"unchecked", "rawtypes"})
public static FieldValue ofObj(@NonNull Object obj) {
switch (FieldValueType.forJavaType(obj.getClass())) {
public static FieldValue ofObj(@NonNull Object obj, @Nullable Column column) {
switch (FieldValueType.forJavaType(obj.getClass(), column)) {
case STRING -> {
return ofStr(obj.toString());
}
Expand Down Expand Up @@ -108,7 +116,7 @@ public static FieldValue ofObj(@NonNull Object obj) {
if (allFieldValues.size() == 1) {
JavaFieldValue singleValue = allFieldValues.iterator().next();
Preconditions.checkArgument(singleValue.getValue() != null, "Wrappers must have a non-null value inside them");
return ofObj(singleValue.getValue());
return ofObj(singleValue.getValue(), column);
}
return ofTuple(new Tuple(obj, allFieldValues));
}
Expand Down Expand Up @@ -153,7 +161,8 @@ public static Comparable<?> getComparable(@NonNull Map<String, Object> values,
if (field.isFlat()) {
Type fieldType = field.getFlatFieldType();
Object rawValue = values.get(field.getName());
return rawValue == null ? null : ofObj(rawValue).getComparable(fieldType);
Column column = field.getField().getColumn();
return rawValue == null ? null : ofObj(rawValue, column).getComparable(fieldType, column);
} else {
List<JavaFieldValue> components = field.flatten()
.map(jf -> new JavaFieldValue(jf, values.get(jf.getName())))
Expand All @@ -163,20 +172,23 @@ public static Comparable<?> getComparable(@NonNull Map<String, Object> values,
}

@NonNull
public Object getRaw(@NonNull Type fieldType) {
if (FieldValueType.forJavaType(fieldType) == FieldValueType.COMPOSITE) {
public Object getRaw(@NonNull Type fieldType, @Nullable Column column) {
if (FieldValueType.forJavaType(fieldType, column) == FieldValueType.COMPOSITE) {
Preconditions.checkState(isTuple(), "Value is not a tuple: %s", this);
Preconditions.checkState(tuple.getType().equals(fieldType),
"Tuple value cannot be converted to a composite of type %s: %s", fieldType, this);
return tuple.asComposite();
}
return getComparable(fieldType);

Comparable<?> cmp = getComparable(fieldType, column);
CustomValueType cvt = CustomValueTypes.getCustomValueType(fieldType, column);
return CustomValueTypes.postconvert(cvt, cmp);
}

@NonNull
@SuppressWarnings({"unchecked", "rawtypes"})
public Comparable<?> getComparable(@NonNull Type fieldType) {
switch (FieldValueType.forJavaType(fieldType)) {
public Comparable<?> getComparable(@NonNull Type fieldType, @Nullable Column column) {
switch (FieldValueType.forJavaType(fieldType, column)) {
case STRING -> {
Preconditions.checkState(isString(), "Value is not a string: " + this);
return str;
Expand Down
Loading

0 comments on commit f6c3f86

Please sign in to comment.