Skip to content

Commit

Permalink
#24: @CustomValueType and FieldValueType improvements
Browse files Browse the repository at this point in the history
- Add `@Deprecated(forRemoval=true)` and "will be removed in YOJ 3.0.0" warnings
to `FieldValueType` pitfalls (`BINARY`, `isSortable()` etc.)
- Remove `@CustomValueType.columnValueType` because it can always be deduced from
`@CustomValueType.columnClass` instead.
- Disallow *recursive* custom value types (that is, types whose converter in turn produce
custom value types).
- Allow preconverted values to be of a subclass of the column class. This is valuable if
columnClass is a enum class, because Java makes anonymous inner classes of enums.
Remove fast-path for postconverted values because it is never used by any real code
(preconvert fast-path is used by `FieldValue`).
- Add "map enum by ordinal" custom value type converter as a demonstration.
Custom value types might become a viable alternatives for `@Column(dbQualifier="...")`.
  • Loading branch information
nvamelichev committed Mar 14, 2024
1 parent 64c5315 commit 37e7399
Show file tree
Hide file tree
Showing 16 changed files with 201 additions and 100 deletions.
19 changes: 10 additions & 9 deletions databind/src/main/java/tech/ydb/yoj/databind/CustomValueType.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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.Schema;

import java.lang.annotation.Inherited;
Expand All @@ -16,24 +17,24 @@
/**
* 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.
* <p>{@link Column#customValueType() @Column(customValueType=...)} annotation on an entity field/record component
* has priority over annotation on the field's/record component's class.
* <p>This annotation is <em>inherited</em>, so make sure that your {@link #converter() converter} either supports all
* possible subclasses of your class, or restrict subclassing by making your class {@code final} or {@code sealed}.
* <p>Defining <em>recursive</em> custom value types is prohibited: that is, you cannot have a custom value type with
* a converter that returns value of {@link #columnClass() another custom value type}.
*/
@Inherited
@Retention(RUNTIME)
@Target({TYPE, FIELD, RECORD_COMPONENT})
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
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 of the values that the {@link ValueConverter#toColumn(Schema.JavaField, Object) toColumn()} method of the {@link #converter() converter}
* returns.
* <p>Column class itself cannot be a custom value type. It must be one of the {@link FieldValueType database column value types supported by YOJ}
* and it must implement {@link Comparable}.
* <p>It is allowed to return value of a subclass of {@code columnClass}, e.g. in case of {@code columnClass} being an {@code enum} class.
*/
@SuppressWarnings("rawtypes")
Class<? extends Comparable> columnClass();
Expand Down
41 changes: 26 additions & 15 deletions databind/src/main/java/tech/ydb/yoj/databind/CustomValueTypes.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ private CustomValueTypes() {
public static Object preconvert(@NonNull JavaField field, @NonNull Object value) {
var cvt = field.getCustomValueType();
if (cvt != null) {
if (cvt.columnClass().equals(value.getClass())) {
// Already preconverted
if (cvt.columnClass().isInstance(value)) {
// Value is already preconverted
return value;
}

Expand All @@ -41,11 +41,6 @@ public static Object preconvert(@NonNull JavaField field, @NonNull Object value)
public static Object postconvert(@NonNull JavaField field, @NonNull Object value) {
var cvt = field.getCustomValueType();
if (cvt != null) {
if (field.getRawType().equals(value.getClass())) {
// Already postconverted
return value;
}

value = createCustomValueTypeConverter(cvt).toJava(field, value);
}
return value;
Expand Down Expand Up @@ -74,15 +69,31 @@ public static CustomValueType getCustomValueType(@NonNull Type type, @Nullable C
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");
var columnClass = cvt.columnClass();

var recursiveCvt = getCustomValueType(columnClass, null);
Preconditions.checkArgument(recursiveCvt == null,
"Defining recursive custom value types is prohibited, but @CustomValueType.columnClass=%s is annotated with %s",
columnClass.getCanonicalName(),
recursiveCvt);

Preconditions.checkArgument(!columnClass.isInterface() && !isAbstract(columnClass.getModifiers()),
"@CustomValueType.columnClass=%s must not be an interface or an abstract class", columnClass.getCanonicalName());

var fvt = FieldValueType.forJavaType(columnClass, null);
Preconditions.checkArgument(!fvt.isComposite(),
"@CustomValueType.columnClass=%s must not map to FieldValueType.COMPOSITE", columnClass.getCanonicalName());
Preconditions.checkArgument(!fvt.isUnknown(),
"@CustomValueType.columnClass=%s must not map to FieldValueType.UNKNOWN", columnClass.getCanonicalName());

var converterClass = cvt.converter();
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);
!converterClass.equals(ValueConverter.NoConverter.class)
&& !converterClass.isInterface()
&& !isAbstract(converterClass.getModifiers())
&& (converterClass.getDeclaringClass() == null || isStatic(converterClass.getModifiers())),
"@CustomValueType.converter=%s must not be an interface, abstract class, non-static inner class, or NoConverter.class",
converterClass.getCanonicalName());
}

return cvt;
Expand Down
85 changes: 51 additions & 34 deletions databind/src/main/java/tech/ydb/yoj/databind/FieldValueType.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,29 @@
public enum FieldValueType {
/**
* Integer value.
* Java-side <strong>must</strong> either be a numeric primitive, or extend {@link Number java.lang.Number} and
* implement {@link Comparable java.lang.Comparable}.
* Java-side <strong>must</strong> be a {@code long}, {@code int}, {@code short} or {@code byte},
* or an instance of their wrapper classes {@code Long}, {@code Integer}, {@code Short} or {@code Byte}.
*/
INTEGER,
/**
* Real (floating-point) number value.
* Java-side <strong>must</strong> either be a numeric primitive, or extend {@link Number java.lang.Number} and
* implement {@link Comparable java.lang.Comparable}.
* Java-side <strong>must</strong> be a {@code double} or a {@code float}, or an instance of their
* wrapper classes {@code Double} or {@code Float}.
*/
REAL,
/**
* String value.
* Java-side <strong>must</strong> be serialized to simple string value.
* Java-side <strong>must</strong> be a {@code String}.
*/
STRING,
/**
* Boolean value.
* Java-side <strong>must</strong> either be an instance of {@link Boolean java.lang.Boolean} or a {@code boolean}
* primitive.
* Java-side <strong>must</strong> either be a {@code boolean} primitive, or an instance of its
* wrapper class {@code Boolean}.
*/
BOOLEAN,
/**
* Enum value. Java-side <strong>must</strong> be an instance of {@link Enum java.lang.Enum}.<br>
* Typically stored as {@link Enum#name() enum constant name} or its {@link Enum#ordinal() ordinal}.
* Enum value. Java-side <strong>must</strong> be a concrete subclass of {@link Enum java.lang.Enum}.
*/
ENUM,
/**
Expand All @@ -61,12 +60,14 @@ public enum FieldValueType {
INTERVAL,
/**
* Binary value: just a stream of uninterpreted bytes.
* Java-side <strong>must</strong> be a byte array.
* Java-side <strong>must</strong> be a {@code byte[]}.
* <p>
* @deprecated It is strongly recommended to use a {@link ByteArray} that is properly {@code Comparable}
* and has a sane {@code equals()}.
*
* @deprecated Support for mapping raw {@code byte[]} will be removed in YOJ 3.0.0.
* Even now, it is strongly recommended to use a {@link ByteArray}: it is properly {@code Comparable}
* and has a sane {@code equals()}, which ensures that queries will work the same for in-memory database and YDB.
*/
@Deprecated
@Deprecated(forRemoval = true)
BINARY,
/**
* Binary value: just a stream of uninterpreted bytes.
Expand All @@ -75,19 +76,24 @@ public enum FieldValueType {
BYTE_ARRAY,
/**
* Composite value. Can contain any other values, including other composite values.<br>
* Java-side must be an immutable POJO with all-args constructor, e.g. a Lombok {@code @Value}-annotated
* class.
* Java-side must be an immutable value reflectable by YOJ: a Java {@code Record},
* a Kotlin {@code data class}, an immutable POJO with all-args constructor annotated with
* {@code @ConstructorProperties} etc.
*/
COMPOSITE,
/**
* Polymorphic object stored in an opaque form (i.e., individual fields cannot be accessed by data binding).<br>
* Serialized form strongly depends on the the marshalling mechanism (<em>e.g.</em>, JSON, YAML, ...).<br>
* Serialized form strongly depends on the the marshalling mechanism (<em>e.g.</em>, JSON, YAML, ...).
*/
OBJECT,
/**
* @deprecated This enum constant will be removed in YOJ 3.0.0; {@link #forJavaType(Type, Column)} will instead
* throw an {@code IllegalArgumentException} if an unmappable type is encountered.
* <p>
* Value type is unknown.<br>
* It <em>might</em> be supported by the data binding implementation, but relying on that fact is not recommended.
*/
@Deprecated(forRemoval = true)
UNKNOWN;

private static final Set<FieldValueType> SORTABLE_VALUE_TYPES = Set.of(
Expand All @@ -107,16 +113,13 @@ public enum FieldValueType {
));

/**
* @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 This method will be removed in YOJ 3.0.0.
* 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 a specific field.
*/
@Deprecated(forRemoval = true)
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
Expand Down Expand Up @@ -145,7 +148,7 @@ public static void registerStringValueType(@NonNull Class<?> clazz) {
public static FieldValueType forJavaType(Type type, Column columnAnnotation) {
var cvt = CustomValueTypes.getCustomValueType(type, columnAnnotation);
if (cvt != null) {
return cvt.columnValueType();
type = cvt.columnClass();
}

boolean flatten = columnAnnotation == null || columnAnnotation.flatten();
Expand All @@ -158,11 +161,6 @@ private static FieldValueType forJavaType(@NonNull Type type) {
if (type instanceof ParameterizedType || type instanceof TypeVariable) {
return OBJECT;
} else if (type instanceof Class<?> 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)) {
Expand Down Expand Up @@ -206,10 +204,13 @@ 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.forJavaType(type, column).isComposite()} where {@code column} is the
* {@link Column &#64;Column} annotation's value.
* @deprecated This method will be removed in YOJ 3.0.0.
* This method does not properly take into account the customizations specified in the
* {@link Column &#64;Column} annotation on the field.
* <br>Please do not call this method directly, instead use
* {@link #forJavaType(Type, Column) FieldValueType.forJavaType(type, column).isComposite()}
* where {@code column} is the {@link Column &#64;Column} annotation's value or {@code null} if
* there is no annotation/you explicitly don't care.
*
* @param type Java object type
* @return {@code true} if {@code type} maps to a composite database value; {@code false} otherwise
Expand All @@ -229,12 +230,28 @@ public boolean isComposite() {
}

/**
* @deprecated This method will be removed in YOJ 3.0.0 along with the {@link #UNKNOWN} enum constant.
*
* @return {@code true} if there is no fitting database value type for the type provided; {@code false} otherwise
*/
@Deprecated(forRemoval = true)
public boolean isUnknown() {
return this == UNKNOWN;
}

/**
* @deprecated This method will be removed in YOJ 3.0.0. This method is misleadingly named and is not generally useful.
* <ul>
* <li>It does not return the list of all Comparable single-column value types (INTERVAL and BOOLEAN are missing).
* In fact, all single-column value types except for BINARY are Comparable.</li>
* <li>What is considered <em>sortable</em> generally depends on your business logic.
* <br>E.g.: Are boolean values sortable or not? They're certainly Comparable.
* <br>E.g.: How do you sort columns with FieldValueType.STRING? Depends on your Locale for in-memory DB and your locale+collation+phase of the moon
* for a real database
* <br><em>etc.</em></li>
* </ul>
*/
@Deprecated(forRemoval = true)
public boolean isSortable() {
return SORTABLE_VALUE_TYPES.contains(this);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package tech.ydb.yoj.databind.converter;

import com.google.common.base.Preconditions;
import lombok.NonNull;
import tech.ydb.yoj.databind.schema.Schema.JavaField;

/**
* A generic converter that can be applied to represent your enum values as their {@link Enum#ordinal() ordinal}s
* instead of their {@link Enum#name() constant name}s or {@link Enum#toString() string representation}s.
* You can use it in a {@link tech.ydb.yoj.databind.schema.Column @Column} annotation, like this:
* <blockquote><pre>
* &#64;Column(
* customValueType=&#64;CustomValueType(
* columnClass=Integer.class,
* converter=EnumOrdinalConverter.class
* )
* )
* </pre></blockquote>
* or as a global default for some of your enum type, like this:
* <blockquote><pre>
* &#64;CustomValueType(
* columnClass=Integer.class,
* converter=EnumOrdinalConverter.class
* )
* public enum MyEnum {
* FOO,
* BAR,
* }
* </pre></blockquote>
*
* @param <E> Java type
*/
public final class EnumOrdinalConverter<E extends Enum<E>> implements ValueConverter<E, Integer> {
private EnumOrdinalConverter() {
}

@Override
public @NonNull Integer toColumn(@NonNull JavaField field, @NonNull E value) {
return value.ordinal();
}

@Override
public @NonNull E toJava(@NonNull JavaField field, @NonNull Integer ordinal) {
@SuppressWarnings("unchecked")
E[] constants = (E[]) field.getRawType().getEnumConstants();
Preconditions.checkState(constants != null, "Not an enum field: %s", field);
Preconditions.checkArgument(ordinal >= 0, "Negative ordinal %s for field %s", ordinal, field);
Preconditions.checkArgument(ordinal < constants.length, "Unknown enum ordinal %s for field %s", ordinal, field);

return constants[ordinal];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@
* <pre>
* &#64;Column(
* customValueType=&#64;CustomValueType(
* columnValueType=STRING,
* columnClass=&lt;your type&gt;,
* columnClass=String.class,
* converter=StringValueConverter.class
* )
* )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,41 @@
* 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
* @param <J> Java field value type
* @param <C> Database column value type. <strong>Must not</strong> be the same type as {@code <J>}.
*/
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
public interface ValueConverter<J, C> {
/**
* Converts a field value to a {@link tech.ydb.yoj.databind.FieldValueType database column value} supported by YOJ.
*
* @param field schema field
* @param v field value, guaranteed to not be {@code null}
* @return database column value corresponding to the Java field value, must not be {@code null}
*
* @see #toJava(JavaField, Object)
*/
@NonNull
C toColumn(@NonNull JavaField field, @NonNull J v);

/**
* Converts a database column value to a Java field value.
*
* @param field schema field
* @param c database column value, guaranteed to not be {@code null}
* @return Java field value corresponding to the database column value, must not be {@code null}
*
* @see #toColumn(JavaField, Object)
*/
@NonNull
J toJava(@NonNull JavaField field, @NonNull C c);

class NoConverter implements ValueConverter<Void, Void> {
/**
* Represents "no custom converter is defined" for {@link tech.ydb.yoj.databind.CustomValueType @CustomValueType}
* annotation inside a {@link tech.ydb.yoj.databind.schema.Column @Column} annotation.
* <p>Non-instantiable, every method including the constructor throws {@link UnsupportedOperationException}.
*/
final class NoConverter implements ValueConverter<Void, Void> {
private NoConverter() {
throw new UnsupportedOperationException("Not instantiable");
}
Expand Down
Loading

0 comments on commit 37e7399

Please sign in to comment.