Skip to content

Commit 880a258

Browse files
committed
#24: EXPERIMENTAL: Allow to have converters between YOJ-supported field value types and custom user types
See the `@CustomValueType` annotation and the `ValueConverter` interface for more information.
1 parent 3d97e1d commit 880a258

File tree

16 files changed

+270
-6
lines changed

16 files changed

+270
-6
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package tech.ydb.yoj.databind;
2+
3+
import tech.ydb.yoj.ExperimentalApi;
4+
5+
import java.lang.annotation.Retention;
6+
import java.lang.annotation.Target;
7+
8+
import static java.lang.annotation.ElementType.TYPE;
9+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
10+
11+
@Target(TYPE)
12+
@Retention(RUNTIME)
13+
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
14+
public @interface CustomValueType {
15+
/**
16+
* Simple value type that the {@link #converter()} represents a custom value type as.
17+
* Cannot be {@link FieldValueType#COMPOSITE} or {@link FieldValueType#UNKNOWN}.
18+
*/
19+
FieldValueType columnValueType();
20+
21+
/**
22+
* Type of value that {@link #converter() converter's} {@link ValueConverter#toColumn(Object) toColumn()} method returns
23+
*/
24+
Class<?> columnClass();
25+
26+
/**
27+
* Converter class. Must have a no-args public constructor.
28+
*/
29+
Class<? extends ValueConverter<?, ?>> converter();
30+
}

databind/src/main/java/tech/ydb/yoj/databind/FieldValueType.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ public static FieldValueType forJavaType(@NonNull Type type) {
156156
if (type instanceof ParameterizedType || type instanceof TypeVariable) {
157157
return OBJECT;
158158
} else if (type instanceof Class<?> clazz) {
159+
var cvt = clazz.getAnnotation(CustomValueType.class);
160+
if (cvt != null) {
161+
return cvt.columnValueType();
162+
}
163+
159164
if (isStringValueType(clazz)) {
160165
return STRING;
161166
} else if (INTEGER_NUMERIC_TYPES.contains(clazz)) {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package tech.ydb.yoj.databind;
2+
3+
import lombok.NonNull;
4+
import tech.ydb.yoj.ExperimentalApi;
5+
6+
/**
7+
* Custom value conversion logic. Must have a no-args public constructor.
8+
*
9+
* @param <V> Java value type
10+
* @param <C> Database column value type
11+
*/
12+
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
13+
public interface ValueConverter<V, C> {
14+
@NonNull
15+
C toColumn(@NonNull V v);
16+
17+
@NonNull
18+
V toJava(@NonNull C c);
19+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package tech.ydb.yoj.databind.schema;
2+
3+
import org.jetbrains.annotations.Nullable;
4+
import tech.ydb.yoj.ExperimentalApi;
5+
6+
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
7+
public final class CustomConverterException extends BindingException {
8+
public CustomConverterException(@Nullable Throwable cause, String message) {
9+
super(cause, __ -> message);
10+
}
11+
}

databind/src/main/java/tech/ydb/yoj/databind/schema/Schema.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import lombok.SneakyThrows;
99
import lombok.Value;
1010
import lombok.With;
11+
import tech.ydb.yoj.ExperimentalApi;
12+
import tech.ydb.yoj.databind.CustomValueType;
1113
import tech.ydb.yoj.databind.FieldValueType;
1214
import tech.ydb.yoj.databind.schema.configuration.SchemaRegistry.SchemaKey;
1315
import tech.ydb.yoj.databind.schema.naming.NamingStrategy;
@@ -695,6 +697,12 @@ private JavaField findField(List<String> path) {
695697
.orElse(null);
696698
}
697699

700+
@Nullable
701+
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/24")
702+
public CustomValueType getCustomValueType() {
703+
return field.getType().getAnnotation(CustomValueType.class);
704+
}
705+
698706
@Override
699707
public String toString() {
700708
return getType().getTypeName() + " " + field.getName();

repository-inmemory/src/main/java/tech/ydb/yoj/repository/test/inmemory/Columns.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ private static Object serialize(Schema.JavaField field, Object value) {
7070
String qualifier = field.getDbTypeQualifier();
7171
try {
7272
Preconditions.checkState(field.isSimple(), "Trying to serialize a non-simple field: %s", field);
73+
74+
value = CommonConverters.preconvert(field.getCustomValueType(), value);
75+
7376
return switch (field.getValueType()) {
7477
case STRING -> CommonConverters.serializeStringValue(type, value);
7578
case ENUM -> DbTypeQualifier.ENUM_TO_STRING.equals(qualifier)
@@ -95,7 +98,8 @@ private static Object deserialize(Schema.JavaField field, Object value) {
9598
String qualifier = field.getDbTypeQualifier();
9699
try {
97100
Preconditions.checkState(field.isSimple(), "Trying to deserialize a non-simple field: %s", field);
98-
return switch (field.getValueType()) {
101+
102+
var deserialized = switch (field.getValueType()) {
99103
case STRING -> CommonConverters.deserializeStringValue(type, value);
100104
case ENUM -> DbTypeQualifier.ENUM_TO_STRING.equals(qualifier)
101105
? CommonConverters.deserializeEnumToStringValue(type, value)
@@ -107,6 +111,8 @@ private static Object deserialize(Schema.JavaField field, Object value) {
107111
case INTERVAL, TIMESTAMP -> value;
108112
default -> throw new IllegalStateException("Don't know how to deserialize field: " + field);
109113
};
114+
115+
return CommonConverters.postconvert(field.getCustomValueType(), deserialized);
110116
} catch (Exception e) {
111117
throw new ConversionException("Could not deserialize value of type <" + type + ">", e);
112118
}

repository-inmemory/src/test/java/tech/ydb/yoj/repository/test/inmemory/TestInMemoryRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import tech.ydb.yoj.repository.test.sample.model.EntityWithValidation;
1717
import tech.ydb.yoj.repository.test.sample.model.IndexedEntity;
1818
import tech.ydb.yoj.repository.test.sample.model.LogEntry;
19+
import tech.ydb.yoj.repository.test.sample.model.NetworkAppliance;
1920
import tech.ydb.yoj.repository.test.sample.model.Primitive;
2021
import tech.ydb.yoj.repository.test.sample.model.Project;
2122
import tech.ydb.yoj.repository.test.sample.model.Referring;
@@ -106,6 +107,11 @@ public Supabubble2Table supabubbles2() {
106107
public Table<UpdateFeedEntry> updateFeedEntries() {
107108
return table(UpdateFeedEntry.class);
108109
}
110+
111+
@Override
112+
public Table<NetworkAppliance> networkAppliances() {
113+
return table(NetworkAppliance.class);
114+
}
109115
}
110116

111117
private static class Supabubble2InMemoryTable extends InMemoryTable<Supabubble2> implements TestEntityOperations.Supabubble2Table {

repository-test/src/main/java/tech/ydb/yoj/repository/test/RepositoryTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.google.common.collect.ImmutableSet;
44
import com.google.common.collect.Iterators;
5+
import lombok.SneakyThrows;
56
import org.assertj.core.api.Assertions;
67
import org.junit.Assert;
78
import org.junit.Test;
@@ -39,6 +40,7 @@
3940
import tech.ydb.yoj.repository.test.sample.model.EntityWithValidation;
4041
import tech.ydb.yoj.repository.test.sample.model.IndexedEntity;
4142
import tech.ydb.yoj.repository.test.sample.model.MultiLevelDirectory;
43+
import tech.ydb.yoj.repository.test.sample.model.NetworkAppliance;
4244
import tech.ydb.yoj.repository.test.sample.model.NonDeserializableEntity;
4345
import tech.ydb.yoj.repository.test.sample.model.NonDeserializableObject;
4446
import tech.ydb.yoj.repository.test.sample.model.Primitive;
@@ -2589,6 +2591,14 @@ public void stringValuedIdInsert() {
25892591
}
25902592
}
25912593

2594+
@Test
2595+
@SneakyThrows
2596+
public void customValueType() {
2597+
var app1 = new NetworkAppliance(new NetworkAppliance.Id("app1"), new NetworkAppliance.Ipv6Address("2e:a0::1"));
2598+
db.tx(() -> db.networkAppliances().insert(app1));
2599+
assertThat(db.tx(() -> db.networkAppliances().find(app1.id()))).isEqualTo(app1);
2600+
}
2601+
25922602
protected void runInTx(Consumer<RepositoryTransaction> action) {
25932603
// We do not retry transactions, because we do not expect conflicts in our test scenarios.
25942604
RepositoryTransaction transaction = startTransaction();

repository-test/src/main/java/tech/ydb/yoj/repository/test/entity/TestEntities.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import tech.ydb.yoj.repository.test.sample.model.EntityWithValidation;
1111
import tech.ydb.yoj.repository.test.sample.model.IndexedEntity;
1212
import tech.ydb.yoj.repository.test.sample.model.LogEntry;
13+
import tech.ydb.yoj.repository.test.sample.model.NetworkAppliance;
1314
import tech.ydb.yoj.repository.test.sample.model.NonDeserializableEntity;
1415
import tech.ydb.yoj.repository.test.sample.model.Primitive;
1516
import tech.ydb.yoj.repository.test.sample.model.Project;
@@ -39,7 +40,8 @@ private TestEntities() {
3940
Supabubble2.class,
4041
NonDeserializableEntity.class,
4142
WithUnflattenableField.class,
42-
UpdateFeedEntry.class
43+
UpdateFeedEntry.class,
44+
NetworkAppliance.class
4345
);
4446

4547
@SuppressWarnings("unchecked")

repository-test/src/main/java/tech/ydb/yoj/repository/test/sample/TestEntityOperations.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import tech.ydb.yoj.repository.test.sample.model.EntityWithValidation;
1212
import tech.ydb.yoj.repository.test.sample.model.IndexedEntity;
1313
import tech.ydb.yoj.repository.test.sample.model.LogEntry;
14+
import tech.ydb.yoj.repository.test.sample.model.NetworkAppliance;
1415
import tech.ydb.yoj.repository.test.sample.model.Primitive;
1516
import tech.ydb.yoj.repository.test.sample.model.Project;
1617
import tech.ydb.yoj.repository.test.sample.model.Referring;
@@ -55,6 +56,8 @@ public interface TestEntityOperations extends BaseDb {
5556

5657
Table<UpdateFeedEntry> updateFeedEntries();
5758

59+
Table<NetworkAppliance> networkAppliances();
60+
5861
class ProjectTable extends AbstractDelegatingTable<Project> {
5962
public ProjectTable(Table<Project> target) {
6063
super(target);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package tech.ydb.yoj.repository.test.sample.model;
2+
3+
import lombok.NonNull;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.Value;
6+
import tech.ydb.yoj.databind.CustomValueType;
7+
import tech.ydb.yoj.databind.ValueConverter;
8+
import tech.ydb.yoj.repository.db.RecordEntity;
9+
10+
import java.net.Inet6Address;
11+
import java.net.InetAddress;
12+
import java.net.UnknownHostException;
13+
14+
import static tech.ydb.yoj.databind.FieldValueType.BINARY;
15+
16+
public record NetworkAppliance(
17+
@NonNull Id id,
18+
@NonNull Ipv6Address address
19+
) implements RecordEntity<NetworkAppliance> {
20+
public record Id(@NonNull String value) implements RecordEntity.Id<NetworkAppliance> {
21+
}
22+
23+
@CustomValueType(columnValueType = BINARY, columnClass = byte[].class, converter = Ipv6Address.Converter.class)
24+
@Value
25+
@RequiredArgsConstructor
26+
public static class Ipv6Address {
27+
Inet6Address addr;
28+
29+
public Ipv6Address(String ip6) throws UnknownHostException {
30+
this((Inet6Address) InetAddress.getByName(ip6));
31+
}
32+
33+
public static final class Converter implements ValueConverter<Ipv6Address, byte[]> {
34+
@Override
35+
public byte @NonNull [] toColumn(@NonNull Ipv6Address ipv6Address) {
36+
return ipv6Address.addr.getAddress();
37+
}
38+
39+
@NonNull
40+
@Override
41+
public Ipv6Address toJava(byte @NonNull [] bytes) {
42+
try {
43+
return new Ipv6Address((Inet6Address) InetAddress.getByAddress(bytes));
44+
} catch (UnknownHostException neverHappens) {
45+
throw new InternalError(neverHappens);
46+
}
47+
}
48+
}
49+
}
50+
}

repository-ydb-v1/src/main/java/tech/ydb/yoj/repository/ydb/yql/YqlPrimitiveType.java

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tech.ydb.yoj.repository.ydb.yql;
22

33
import com.google.common.primitives.Primitives;
4+
import com.google.common.reflect.TypeToken;
45
import com.google.protobuf.ByteString;
56
import com.google.protobuf.Descriptors;
67
import com.google.protobuf.UnsafeByteOperations;
@@ -12,6 +13,7 @@
1213
import lombok.NonNull;
1314
import lombok.Value;
1415
import lombok.With;
16+
import tech.ydb.yoj.databind.CustomValueType;
1517
import tech.ydb.yoj.databind.FieldValueType;
1618
import tech.ydb.yoj.databind.schema.Column;
1719
import tech.ydb.yoj.databind.schema.Schema.JavaField;
@@ -39,6 +41,8 @@
3941
import static tech.ydb.yoj.repository.db.common.CommonConverters.enumValueSetter;
4042
import static tech.ydb.yoj.repository.db.common.CommonConverters.opaqueObjectValueGetter;
4143
import static tech.ydb.yoj.repository.db.common.CommonConverters.opaqueObjectValueSetter;
44+
import static tech.ydb.yoj.repository.db.common.CommonConverters.postconvert;
45+
import static tech.ydb.yoj.repository.db.common.CommonConverters.preconvert;
4246
import static tech.ydb.yoj.repository.db.common.CommonConverters.stringValueGetter;
4347
import static tech.ydb.yoj.repository.db.common.CommonConverters.stringValueSetter;
4448

@@ -328,7 +332,9 @@ public static void resetStringDefaultTypeToDefaults() {
328332

329333
@NonNull
330334
public static YqlPrimitiveType of(Type javaType) {
331-
return resolveYqlType(javaType, FieldValueType.forJavaType(javaType), null, null);
335+
var cvt = TypeToken.of(javaType).getRawType().getAnnotation(CustomValueType.class);
336+
var valueType = FieldValueType.forJavaType(javaType);
337+
return resolveYqlType(javaType, valueType, null, null, cvt);
332338
}
333339

334340
/**
@@ -344,7 +350,34 @@ public static YqlPrimitiveType of(JavaField column) {
344350
String columnType = column.getDbType();
345351
PrimitiveTypeId yqlType = (columnType == null) ? null : convertToYqlType(columnType);
346352

347-
return resolveYqlType(column.getType(), column.getValueType(), yqlType, column.getDbTypeQualifier());
353+
return resolveYqlType(column.getType(), column.getValueType(), yqlType, column.getDbTypeQualifier(), column.getCustomValueType());
354+
}
355+
356+
@NonNull
357+
private static YqlPrimitiveType resolveYqlType(Type javaType, FieldValueType valueType,
358+
PrimitiveTypeId yqlType, String qualifier,
359+
CustomValueType cvt) {
360+
if (cvt != null && cvt.columnValueType() != valueType) {
361+
throw new IllegalStateException("This should never happen: detected FieldValueType must == @CustomValueType.columnValueType(), but got: "
362+
+ valueType + " != " + cvt.columnValueType());
363+
}
364+
365+
var underlyingType = resolveYqlType(
366+
cvt != null ? cvt.columnClass() : javaType,
367+
valueType,
368+
yqlType,
369+
qualifier
370+
);
371+
if (cvt == null) {
372+
return underlyingType;
373+
}
374+
375+
return new YqlPrimitiveType(
376+
underlyingType.javaType,
377+
underlyingType.yqlType,
378+
(b, o) -> underlyingType.setter.accept(b, preconvert(cvt, o)),
379+
v -> postconvert(cvt, underlyingType.getter.apply(v))
380+
);
348381
}
349382

350383
@NonNull

repository-ydb-v1/src/test/java/tech/ydb/yoj/repository/ydb/TestYdbRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import tech.ydb.yoj.repository.test.sample.model.EntityWithValidation;
2121
import tech.ydb.yoj.repository.test.sample.model.IndexedEntity;
2222
import tech.ydb.yoj.repository.test.sample.model.LogEntry;
23+
import tech.ydb.yoj.repository.test.sample.model.NetworkAppliance;
2324
import tech.ydb.yoj.repository.test.sample.model.Primitive;
2425
import tech.ydb.yoj.repository.test.sample.model.Project;
2526
import tech.ydb.yoj.repository.test.sample.model.Referring;
@@ -125,6 +126,11 @@ public Supabubble2Table supabubbles2() {
125126
public Table<UpdateFeedEntry> updateFeedEntries() {
126127
return table(UpdateFeedEntry.class);
127128
}
129+
130+
@Override
131+
public Table<NetworkAppliance> networkAppliances() {
132+
return table(NetworkAppliance.class);
133+
}
128134
}
129135

130136
private static class YdbSupabubble2Table extends YdbTable<Supabubble2> implements Supabubble2Table {

0 commit comments

Comments
 (0)