Skip to content

Commit 3d97e1d

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 33297ee commit 3d97e1d

File tree

14 files changed

+284
-12
lines changed

14 files changed

+284
-12
lines changed
Lines changed: 23 additions & 0 deletions
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+
}

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

Lines changed: 36 additions & 4 deletions
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;
@@ -95,15 +98,33 @@ public enum FieldValueType {
9598
String.class
9699
));
97100

101+
/**
102+
* @deprecated It is recommended to use the new {@link StringValueType} annotation instead of calling this method.
103+
* <p>
104+
* 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
105+
* method. A later version of YOJ will provide an alternative way of doing so, probably by calling some method on {@code SchemaRegistry}.
106+
*
107+
* @param clazz class to register as string-value. Must either be final or sealed with permissible final-only implementations.
108+
* All permissible implementations of a sealed class will be registered automatically.
109+
*/
110+
@Deprecated
111+
@ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/21")
98112
public static void registerStringValueType(@NonNull Class<?> clazz) {
113+
ensureValidStringValueType(clazz);
114+
STRING_VALUE_TYPES.add(clazz);
115+
if (clazz.isSealed()) {
116+
STRING_VALUE_TYPES.addAll(Arrays.asList(clazz.getPermittedSubclasses()));
117+
}
118+
}
119+
120+
private static void ensureValidStringValueType(@NotNull Class<?> clazz) {
99121
boolean isFinal = isFinal(clazz.getModifiers());
100122
boolean isSealed = clazz.isSealed();
101123
Preconditions.checkArgument(isFinal || isSealed,
102124
"String-value type must either be final or sealed, but got: %s", clazz);
103125

104-
STRING_VALUE_TYPES.add(clazz);
105126
if (isSealed) {
106-
Arrays.stream(clazz.getPermittedSubclasses()).forEach(FieldValueType::registerStringValueType);
127+
Arrays.stream(clazz.getPermittedSubclasses()).forEach(FieldValueType::ensureValidStringValueType);
107128
}
108129
}
109130

@@ -135,8 +156,7 @@ public static FieldValueType forJavaType(@NonNull Type type) {
135156
if (type instanceof ParameterizedType || type instanceof TypeVariable) {
136157
return OBJECT;
137158
} else if (type instanceof Class<?> clazz) {
138-
// FIXME: remove static configuration here, more it to e.g. a class annotation (or SchemaRegistry)
139-
if (STRING_VALUE_TYPES.contains(clazz)) {
159+
if (isStringValueType(clazz)) {
140160
return STRING;
141161
} else if (INTEGER_NUMERIC_TYPES.contains(clazz)) {
142162
return INTEGER;
@@ -169,6 +189,18 @@ public static FieldValueType forJavaType(@NonNull Type type) {
169189
}
170190
}
171191

192+
private static boolean isStringValueType(Class<?> clazz) {
193+
if (STRING_VALUE_TYPES.contains(clazz)) {
194+
return true;
195+
}
196+
if (clazz.getAnnotation(StringValueType.class) != null) {
197+
// FIXME: Move the Set of string-value types to SchemaRegistry
198+
registerStringValueType(clazz);
199+
return true;
200+
}
201+
return false;
202+
}
203+
172204
/**
173205
* Checks whether Java object of type {@code type} is mapped to a composite database value
174206
* (i.e. > 1 database field)

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

Lines changed: 78 additions & 3 deletions
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) {
Lines changed: 25 additions & 0 deletions
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+
}

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
@@ -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 {

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import tech.ydb.yoj.repository.test.sample.model.TypeFreak.A;
5151
import tech.ydb.yoj.repository.test.sample.model.TypeFreak.B;
5252
import tech.ydb.yoj.repository.test.sample.model.TypeFreak.Embedded;
53+
import tech.ydb.yoj.repository.test.sample.model.UpdateFeedEntry;
5354
import tech.ydb.yoj.repository.test.sample.model.WithUnflattenableField;
5455

5556
import java.time.Instant;
@@ -59,6 +60,7 @@
5960
import java.util.HashSet;
6061
import java.util.Iterator;
6162
import java.util.List;
63+
import java.util.Map;
6264
import java.util.Objects;
6365
import java.util.Set;
6466
import java.util.concurrent.atomic.AtomicBoolean;
@@ -2090,7 +2092,7 @@ public void rangeLock() {
20902092

20912093
parallelTx(true, true, t -> t.find(new Range<>(new Complex.Id(1, 2L, null, null)))); //lock on range
20922094
parallelTx(false, false, t -> t.find(new Range<>(new Complex.Id(1, 2L, null, null)))); //lock on range
2093-
2095+
20942096
parallelTx(false, true, t -> t.find(new Range<>(new Complex.Id(2, 2L, null, null)))); //not lock on another range
20952097
parallelTx(false, false, t -> t.find(new Range<>(new Complex.Id(2, 2L, null, null)))); //not lock on another range
20962098
}
@@ -2563,6 +2565,30 @@ public void readAndFailOnInconsistentDataSucceedOnRetry() {
25632565
assertThat(twoElements).hasSize(2);
25642566
}
25652567

2568+
@Test
2569+
public void stringValuedIdInsert() {
2570+
Map<UpdateFeedEntry.Id, UpdateFeedEntry> inserted = new HashMap<>();
2571+
for (int i = 0; i < 100; i++) {
2572+
var snap = new UpdateFeedEntry(UpdateFeedEntry.Id.generate("insert"), Instant.now(), "payload-" + i);
2573+
db.tx(() -> db.updateFeedEntries().insert(snap));
2574+
inserted.put(snap.getId(), snap);
2575+
}
2576+
2577+
assertThat(db.tx(() -> db.updateFeedEntries().find(inserted.keySet())))
2578+
.containsExactlyInAnyOrderElementsOf(inserted.values());
2579+
2580+
assertThat(db.tx(() -> db.updateFeedEntries().list(ListRequest.builder(UpdateFeedEntry.class)
2581+
.filter(fb -> fb.where("id").in(inserted.keySet()))
2582+
.build())))
2583+
.containsExactlyInAnyOrderElementsOf(inserted.values());
2584+
2585+
for (var e : inserted.entrySet()) {
2586+
assertThat(db.tx(() -> db.updateFeedEntries().query()
2587+
.filter(fb -> fb.where("id").eq(e.getKey()))
2588+
.findOne())).isEqualTo(e.getValue());
2589+
}
2590+
}
2591+
25662592
protected void runInTx(Consumer<RepositoryTransaction> action) {
25672593
// We do not retry transactions, because we do not expect conflicts in our test scenarios.
25682594
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
@@ -18,6 +18,7 @@
1818
import tech.ydb.yoj.repository.test.sample.model.Supabubble2;
1919
import tech.ydb.yoj.repository.test.sample.model.Team;
2020
import tech.ydb.yoj.repository.test.sample.model.TypeFreak;
21+
import tech.ydb.yoj.repository.test.sample.model.UpdateFeedEntry;
2122
import tech.ydb.yoj.repository.test.sample.model.WithUnflattenableField;
2223

2324
import java.util.List;
@@ -37,7 +38,8 @@ private TestEntities() {
3738
Supabubble.class,
3839
Supabubble2.class,
3940
NonDeserializableEntity.class,
40-
WithUnflattenableField.class
41+
WithUnflattenableField.class,
42+
UpdateFeedEntry.class
4143
);
4244

4345
@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
@@ -18,6 +18,7 @@
1818
import tech.ydb.yoj.repository.test.sample.model.Supabubble2;
1919
import tech.ydb.yoj.repository.test.sample.model.Team;
2020
import tech.ydb.yoj.repository.test.sample.model.TypeFreak;
21+
import tech.ydb.yoj.repository.test.sample.model.UpdateFeedEntry;
2122

2223
import java.util.ArrayList;
2324
import java.util.Collection;
@@ -52,6 +53,8 @@ public interface TestEntityOperations extends BaseDb {
5253

5354
Supabubble2Table supabubbles2();
5455

56+
Table<UpdateFeedEntry> updateFeedEntries();
57+
5558
class ProjectTable extends AbstractDelegatingTable<Project> {
5659
public ProjectTable(Table<Project> target) {
5760
super(target);

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

Lines changed: 1 addition & 1 deletion
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> {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ public static class StringView implements Table.ViewId<TypeFreak> {
112112
}
113113

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

0 commit comments

Comments
 (0)