Skip to content

Commit 14f61c1

Browse files
committed
#21: EXPERIMENTAL: Allow Entity IDs to be String-valued types
...represented as string values in YDB and marshaled using `valueOf()`/`fromString()` and `toString()`` methods. To enable this experimental feature, annotate your ID type with `@StringValueType(entityId=true)`.
1 parent c0e80c7 commit 14f61c1

File tree

14 files changed

+284
-12
lines changed

14 files changed

+284
-12
lines changed
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package tech.ydb.yoj;
2+
3+
import java.lang.annotation.Retention;
4+
import java.lang.annotation.Target;
5+
6+
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
7+
import static java.lang.annotation.ElementType.FIELD;
8+
import static java.lang.annotation.ElementType.METHOD;
9+
import static java.lang.annotation.ElementType.PARAMETER;
10+
import static java.lang.annotation.ElementType.TYPE;
11+
import static java.lang.annotation.RetentionPolicy.SOURCE;
12+
13+
/**
14+
* Annotates experimental features.
15+
*/
16+
@Target({TYPE, FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
17+
@Retention(SOURCE)
18+
public @interface ExperimentalApi {
19+
/**
20+
* @return URL of the GitHub issue tracking the experimental API
21+
*/
22+
String issue();
23+
}

Diff for: databind/src/main/java/tech/ydb/yoj/databind/FieldValueType.java

+36-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import com.google.common.base.Preconditions;
44
import lombok.NonNull;
5+
import org.jetbrains.annotations.NotNull;
6+
import tech.ydb.yoj.ExperimentalApi;
57
import tech.ydb.yoj.databind.schema.Column;
8+
import tech.ydb.yoj.databind.schema.StringValueType;
69

710
import java.lang.reflect.ParameterizedType;
811
import java.lang.reflect.Type;
@@ -100,15 +103,33 @@ public enum FieldValueType {
100103
String.class
101104
));
102105

106+
/**
107+
* @deprecated It is recommended to use the new {@link StringValueType} annotation instead of calling this method.
108+
* <p>
109+
* To register a class <em>not in your code</em> (e.g., {@code UUID} from the JDK) as a string-value type, continue using this
110+
* method. A later version of YOJ will provide an alternative way of doing so, probably by calling some method on {@code SchemaRegistry}.
111+
*
112+
* @param clazz class to register as string-value. Must either be final or sealed with permissible final-only implementations.
113+
* All permissible implementations of a sealed class will be registered automatically.
114+
*/
115+
@Deprecated
116+
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/21")
103117
public static void registerStringValueType(@NonNull Class<?> clazz) {
118+
ensureValidStringValueType(clazz);
119+
STRING_VALUE_TYPES.add(clazz);
120+
if (clazz.isSealed()) {
121+
STRING_VALUE_TYPES.addAll(Arrays.asList(clazz.getPermittedSubclasses()));
122+
}
123+
}
124+
125+
private static void ensureValidStringValueType(@NotNull Class<?> clazz) {
104126
boolean isFinal = isFinal(clazz.getModifiers());
105127
boolean isSealed = clazz.isSealed();
106128
Preconditions.checkArgument(isFinal || isSealed,
107129
"String-value type must either be final or sealed, but got: %s", clazz);
108130

109-
STRING_VALUE_TYPES.add(clazz);
110131
if (isSealed) {
111-
Arrays.stream(clazz.getPermittedSubclasses()).forEach(FieldValueType::registerStringValueType);
132+
Arrays.stream(clazz.getPermittedSubclasses()).forEach(FieldValueType::ensureValidStringValueType);
112133
}
113134
}
114135

@@ -140,8 +161,7 @@ public static FieldValueType forJavaType(@NonNull Type type) {
140161
if (type instanceof ParameterizedType || type instanceof TypeVariable) {
141162
return OBJECT;
142163
} else if (type instanceof Class<?> clazz) {
143-
// FIXME: remove static configuration here, more it to e.g. a class annotation (or SchemaRegistry)
144-
if (STRING_VALUE_TYPES.contains(clazz)) {
164+
if (isStringValueType(clazz)) {
145165
return STRING;
146166
} else if (INTEGER_NUMERIC_TYPES.contains(clazz)) {
147167
return INTEGER;
@@ -176,6 +196,18 @@ public static FieldValueType forJavaType(@NonNull Type type) {
176196
}
177197
}
178198

199+
private static boolean isStringValueType(Class<?> clazz) {
200+
if (STRING_VALUE_TYPES.contains(clazz)) {
201+
return true;
202+
}
203+
if (clazz.getAnnotation(StringValueType.class) != null) {
204+
// FIXME: Move the Set of string-value types to SchemaRegistry
205+
registerStringValueType(clazz);
206+
return true;
207+
}
208+
return false;
209+
}
210+
179211
/**
180212
* Checks whether Java object of type {@code type} is mapped to a composite database value
181213
* (i.e. > 1 database field)

Diff for: databind/src/main/java/tech/ydb/yoj/databind/schema/Schema.java

+78-3
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import static java.util.stream.Collectors.toList;
4545
import static java.util.stream.Collectors.toMap;
4646
import static lombok.AccessLevel.PROTECTED;
47+
import static tech.ydb.yoj.databind.FieldValueType.STRING;
4748

4849
public abstract class Schema<T> {
4950
public static final String PATH_DELIMITER = ".";
@@ -194,9 +195,20 @@ protected Schema(Schema<?> schema, String subSchemaFieldPath) {
194195

195196
staticName = schema.staticName;
196197
globalIndexes = schema.globalIndexes;
197-
fields = (subSchemaField.fields == null)
198-
? List.of()
199-
: subSchemaField.fields.stream().map(this::newRootJavaField).toList();
198+
199+
if (subSchemaField.fields != null) {
200+
fields = subSchemaField.fields.stream().map(this::newRootJavaField).toList();
201+
} else {
202+
var subSchemaFieldType = subSchemaField.getRawType();
203+
if (subSchemaFieldType.getAnnotation(StringValueType.class) != null
204+
&& subSchemaFieldType.getAnnotation(StringValueType.class).entityId()) {
205+
var dummyField = new JavaField(new DummyStringValueField(subSchemaField), subSchemaField, __ -> true);
206+
dummyField.setName(subSchemaField.getName());
207+
fields = List.of(dummyField);
208+
} else {
209+
fields = List.of();
210+
}
211+
}
200212
ttlModifier = schema.ttlModifier;
201213
changefeeds = schema.changefeeds;
202214
}
@@ -379,6 +391,65 @@ public final String toString() {
379391
+ " [type=" + getType().getName() + "]";
380392
}
381393

394+
private static final class DummyStringValueField implements ReflectField {
395+
private final JavaField donor;
396+
397+
private DummyStringValueField(JavaField donor) {
398+
this.donor = donor;
399+
}
400+
401+
@Override
402+
public String getName() {
403+
return donor.getName();
404+
}
405+
406+
@Override
407+
public Column getColumn() {
408+
return donor.getField().getColumn();
409+
}
410+
411+
@Override
412+
public Type getGenericType() {
413+
return String.class;
414+
}
415+
416+
@Override
417+
public Class<?> getType() {
418+
return String.class;
419+
}
420+
421+
@Override
422+
public ReflectType<?> getReflectType() {
423+
return donor.getField().getReflectType();
424+
}
425+
426+
@Override
427+
public Object getValue(Object containingObject) {
428+
Preconditions.checkArgument(donor.getRawType().isInstance(containingObject),
429+
"Tried to get value of a string-value field '%s' on an invalid type: expected %s, got %s",
430+
donor.getPath(),
431+
donor.getRawType(),
432+
containingObject == null ? "<null value>" : containingObject.getClass()
433+
);
434+
return containingObject.toString();
435+
}
436+
437+
@Override
438+
public Collection<ReflectField> getChildren() {
439+
return Set.of();
440+
}
441+
442+
@Override
443+
public FieldValueType getValueType() {
444+
return STRING;
445+
}
446+
447+
@Override
448+
public String toString() {
449+
return "DummyStringValueField[donor=" + donor + "]";
450+
}
451+
}
452+
382453
public static final class JavaField {
383454
@Getter
384455
private final ReflectField field;
@@ -462,6 +533,10 @@ public Type getType() {
462533
return field.getGenericType();
463534
}
464535

536+
public Class<?> getRawType() {
537+
return field.getType();
538+
}
539+
465540
// FIXME: make this method non-public
466541
@Deprecated
467542
public void setName(String newName) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package tech.ydb.yoj.databind.schema;
2+
3+
import tech.ydb.yoj.ExperimentalApi;
4+
5+
import java.lang.annotation.Inherited;
6+
import java.lang.annotation.Retention;
7+
import java.lang.annotation.Target;
8+
9+
import static java.lang.annotation.ElementType.TYPE;
10+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
11+
12+
/**
13+
* Marks the type as a <em>String-value type</em>, serialized to the database as text by calling {@code toString()} and
14+
* deserialized back using {@code static [TYPE] fromString(String)} or {@code static [TYPE] valueOf(String)} method.
15+
*/
16+
@Target(TYPE)
17+
@Retention(RUNTIME)
18+
@Inherited
19+
public @interface StringValueType {
20+
/**
21+
* Experimental feature: Represent the whole Entity ID as a String.
22+
*/
23+
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/21")
24+
boolean entityId();
25+
}

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

+6
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import tech.ydb.yoj.repository.test.sample.model.Supabubble2;
2424
import tech.ydb.yoj.repository.test.sample.model.Team;
2525
import tech.ydb.yoj.repository.test.sample.model.TypeFreak;
26+
import tech.ydb.yoj.repository.test.sample.model.UpdateFeedEntry;
2627

2728
import java.util.Set;
2829

@@ -100,6 +101,11 @@ public SupabubbleTable supabubbles() {
100101
public Supabubble2Table supabubbles2() {
101102
return new Supabubble2InMemoryTable(getMemory(Supabubble2.class));
102103
}
104+
105+
@Override
106+
public Table<UpdateFeedEntry> updateFeedEntries() {
107+
return table(UpdateFeedEntry.class);
108+
}
103109
}
104110

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

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

+27-1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import tech.ydb.yoj.repository.test.sample.model.TypeFreak.A;
5252
import tech.ydb.yoj.repository.test.sample.model.TypeFreak.B;
5353
import tech.ydb.yoj.repository.test.sample.model.TypeFreak.Embedded;
54+
import tech.ydb.yoj.repository.test.sample.model.UpdateFeedEntry;
5455
import tech.ydb.yoj.repository.test.sample.model.WithUnflattenableField;
5556

5657
import java.time.Instant;
@@ -60,6 +61,7 @@
6061
import java.util.HashSet;
6162
import java.util.Iterator;
6263
import java.util.List;
64+
import java.util.Map;
6365
import java.util.Objects;
6466
import java.util.Set;
6567
import java.util.concurrent.atomic.AtomicBoolean;
@@ -2095,7 +2097,7 @@ public void rangeLock() {
20952097

20962098
parallelTx(true, true, t -> t.find(new Range<>(new Complex.Id(1, 2L, null, null)))); //lock on range
20972099
parallelTx(false, false, t -> t.find(new Range<>(new Complex.Id(1, 2L, null, null)))); //lock on range
2098-
2100+
20992101
parallelTx(false, true, t -> t.find(new Range<>(new Complex.Id(2, 2L, null, null)))); //not lock on another range
21002102
parallelTx(false, false, t -> t.find(new Range<>(new Complex.Id(2, 2L, null, null)))); //not lock on another range
21012103
}
@@ -2606,6 +2608,30 @@ public void readAndFailOnInconsistentDataSucceedOnRetry() {
26062608
assertThat(twoElements).hasSize(2);
26072609
}
26082610

2611+
@Test
2612+
public void stringValuedIdInsert() {
2613+
Map<UpdateFeedEntry.Id, UpdateFeedEntry> inserted = new HashMap<>();
2614+
for (int i = 0; i < 100; i++) {
2615+
var snap = new UpdateFeedEntry(UpdateFeedEntry.Id.generate("insert"), Instant.now(), "payload-" + i);
2616+
db.tx(() -> db.updateFeedEntries().insert(snap));
2617+
inserted.put(snap.getId(), snap);
2618+
}
2619+
2620+
assertThat(db.tx(() -> db.updateFeedEntries().find(inserted.keySet())))
2621+
.containsExactlyInAnyOrderElementsOf(inserted.values());
2622+
2623+
assertThat(db.tx(() -> db.updateFeedEntries().list(ListRequest.builder(UpdateFeedEntry.class)
2624+
.filter(fb -> fb.where("id").in(inserted.keySet()))
2625+
.build())))
2626+
.containsExactlyInAnyOrderElementsOf(inserted.values());
2627+
2628+
for (var e : inserted.entrySet()) {
2629+
assertThat(db.tx(() -> db.updateFeedEntries().query()
2630+
.filter(fb -> fb.where("id").eq(e.getKey()))
2631+
.findOne())).isEqualTo(e.getValue());
2632+
}
2633+
}
2634+
26092635
protected void runInTx(Consumer<RepositoryTransaction> action) {
26102636
// We do not retry transactions, because we do not expect conflicts in our test scenarios.
26112637
RepositoryTransaction transaction = startTransaction();

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import tech.ydb.yoj.repository.test.sample.model.Supabubble2;
2020
import tech.ydb.yoj.repository.test.sample.model.Team;
2121
import tech.ydb.yoj.repository.test.sample.model.TypeFreak;
22+
import tech.ydb.yoj.repository.test.sample.model.UpdateFeedEntry;
2223
import tech.ydb.yoj.repository.test.sample.model.WithUnflattenableField;
2324

2425
import java.util.List;
@@ -39,7 +40,8 @@ private TestEntities() {
3940
Supabubble.class,
4041
Supabubble2.class,
4142
NonDeserializableEntity.class,
42-
WithUnflattenableField.class
43+
WithUnflattenableField.class,
44+
UpdateFeedEntry.class
4345
);
4446

4547
@SuppressWarnings("unchecked")

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

+3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import tech.ydb.yoj.repository.test.sample.model.Supabubble2;
2020
import tech.ydb.yoj.repository.test.sample.model.Team;
2121
import tech.ydb.yoj.repository.test.sample.model.TypeFreak;
22+
import tech.ydb.yoj.repository.test.sample.model.UpdateFeedEntry;
2223

2324
import java.util.ArrayList;
2425
import java.util.Collection;
@@ -57,6 +58,8 @@ default Table<BytePkEntity> bytePkEntities() {
5758

5859
Supabubble2Table supabubbles2();
5960

61+
Table<UpdateFeedEntry> updateFeedEntries();
62+
6063
class ProjectTable extends AbstractDelegatingTable<Project> {
6164
public ProjectTable(Table<Project> target) {
6265
super(target);

Diff for: repository-test/src/main/java/tech/ydb/yoj/repository/test/sample/model/ChangefeedEntity.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
format = JSON,
1616
virtualTimestamps = true,
1717
retentionPeriod = "PT1H",
18-
initialScan = true
18+
initialScan = false
1919
)
2020
@Changefeed(name = "test-changefeed2")
2121
public class ChangefeedEntity implements Entity<ChangefeedEntity> {

Diff for: repository-test/src/main/java/tech/ydb/yoj/repository/test/sample/model/TypeFreak.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public String toString() {
138138
}
139139

140140
/**
141-
* String-valued type with canonical static factory ({@link #valueOf(String)}) and a matching {@link #toString()}
141+
* String-value type with canonical static factory ({@link #valueOf(String)}) and a matching {@link #toString()}
142142
* instance method.
143143
* <p><em>E.g.,</em> {@code "XYZ-100500" <=> new Ticket(queue="XYZ", num=100500)}
144144
*/

0 commit comments

Comments
 (0)