From f1b3e9817eb2644d493df3da5ea2dbe5ce5e5031 Mon Sep 17 00:00:00 2001 From: Nikolai Amelichev Date: Fri, 17 Oct 2025 21:22:07 +0200 Subject: [PATCH] Improve Range API and documentation --- .../test/inmemory/InMemoryTxLockWatcher.java | 4 +- .../ydb/statement/FindRangeStatement.java | 38 ++-- .../ydb/table/BatchFindSpliterator.java | 7 +- .../ydb/yoj/repository/db/EntityIdSchema.java | 47 ++++ .../tech/ydb/yoj/repository/db/Range.java | 213 +++++++++++++----- 5 files changed, 233 insertions(+), 76 deletions(-) diff --git a/repository-inmemory/src/main/java/tech/ydb/yoj/repository/test/inmemory/InMemoryTxLockWatcher.java b/repository-inmemory/src/main/java/tech/ydb/yoj/repository/test/inmemory/InMemoryTxLockWatcher.java index d9495a8b..5bec3284 100644 --- a/repository-inmemory/src/main/java/tech/ydb/yoj/repository/test/inmemory/InMemoryTxLockWatcher.java +++ b/repository-inmemory/src/main/java/tech/ydb/yoj/repository/test/inmemory/InMemoryTxLockWatcher.java @@ -34,12 +34,12 @@ public , ID extends Entity.Id> void markRangeRead(TableDe } public , ID extends Entity.Id> void markRangeRead(TableDescriptor tableDescriptor, EntitySchema schema, Map map) { - Range range = Range.create(schema.getIdSchema(), map); + Range range = schema.getIdSchema().newRangeInstance(map); markRangeRead(tableDescriptor, range); } public , ID extends Entity.Id> void markTableRead(TableDescriptor tableDescriptor, EntitySchema schema) { - Range range = Range.create(schema.getIdSchema(), Map.of()); + Range range = schema.getIdSchema().newRangeInstance(Map.of()); markRangeRead(tableDescriptor, range); } diff --git a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/FindRangeStatement.java b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/FindRangeStatement.java index d85775c4..358096a6 100644 --- a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/FindRangeStatement.java +++ b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/FindRangeStatement.java @@ -1,9 +1,10 @@ package tech.ydb.yoj.repository.ydb.statement; -import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.RequiredArgsConstructor; import tech.ydb.proto.ValueProtos; import tech.ydb.yoj.databind.schema.Schema; +import tech.ydb.yoj.databind.schema.Schema.JavaField; import tech.ydb.yoj.repository.db.Entity; import tech.ydb.yoj.repository.db.EntitySchema; import tech.ydb.yoj.repository.db.Range; @@ -36,10 +37,10 @@ public FindRangeStatement( .collect(toList()); } - private Stream toParams(Set names, FindRangeStatement.RangeBound rangeBound) { + private Stream toParams(Set fields, FindRangeStatement.RangeBound rangeBound) { return schema.flattenId().stream() - .filter(f -> names.contains(f.getName())) - .map(c -> new FindRangeStatement.YqlStatementRangeParam(YqlType.of(c), c.getName(), rangeBound)); + .filter(fields::contains) + .map(c -> new FindRangeStatement.YqlStatementRangeParam(YqlType.of(c), c, rangeBound)); } @Override @@ -48,7 +49,7 @@ public Map toQueryParameters(Range parameter .map(YqlStatementRangeParam.class::cast) .collect(toMap( YqlStatementParam::getVar, - p -> createTQueryParameter(p.getType(), p.rangeBound.map(parameters).get(p.rangeName), p.isOptional())) + p -> createTQueryParameter(p.getType(), p.rangeBound.map(parameters).get(p.field), p.isOptional())) ); } @@ -74,31 +75,32 @@ public String getQuery(String tablespace) { private String predicationVars() { return getParams().stream() .map(YqlStatementRangeParam.class::cast) - .map(p -> "(" + escape(p.rangeName) + p.rangeBound.op + p.getVar() + ")") + .map(p -> "(" + escape(p.field.getName()) + p.rangeBound.op + p.getVar() + ")") .collect(joining(" AND ")); } - @AllArgsConstructor - enum RangeBound { + @RequiredArgsConstructor + private enum RangeBound { EQ("=", Range::getEqMap), - MAX("<=", Range::getMaxMap), - MIN(">=", Range::getMinMap); - String op; - Function> mapper; + MIN(">=", Range::getMinMap), + MAX("<=", Range::getMaxMap); - public Map map(Range range) { + private final String op; + private final Function, Map> mapper; + + public Map map(Range range) { return mapper.apply(range); } } - static class YqlStatementRangeParam extends YqlStatementParam { + private static final class YqlStatementRangeParam extends YqlStatementParam { private final RangeBound rangeBound; - private final String rangeName; + private final JavaField field; - YqlStatementRangeParam(YqlType type, String name, RangeBound rangeBound) { - super(type, rangeBound.name() + "_" + name, true); + private YqlStatementRangeParam(YqlType type, JavaField field, RangeBound rangeBound) { + super(type, rangeBound.name() + "_" + field.getName(), true); this.rangeBound = rangeBound; - this.rangeName = name; + this.field = field; } } } diff --git a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/table/BatchFindSpliterator.java b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/table/BatchFindSpliterator.java index fceeaa8e..5414cfe2 100644 --- a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/table/BatchFindSpliterator.java +++ b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/table/BatchFindSpliterator.java @@ -1,6 +1,7 @@ package tech.ydb.yoj.repository.ydb.table; import tech.ydb.yoj.databind.schema.Schema; +import tech.ydb.yoj.databind.schema.Schema.JavaField; import tech.ydb.yoj.repository.db.Entity; import tech.ydb.yoj.repository.db.EntityIdSchema; import tech.ydb.yoj.repository.db.Range; @@ -43,11 +44,11 @@ abstract class BatchFindSpliterator, ID extends Entity.Id this.top = YqlLimit.top(batchSize); if (partial != null) { Range range = Range.create(this.idSchema, partial); - Map eqMap = range.getEqMap(); + Map eqMap = range.getEqMap(); this.initialPartialPredicates = this.idSchema .flattenFields().stream() - .filter(f -> eqMap.containsKey(f.getName())) - .map(f -> YqlPredicate.eq(f.getPath(), eqMap.get(f.getName()))) + .filter(eqMap::containsKey) + .map(f -> YqlPredicate.eq(f.getPath(), eqMap.get(f))) .collect(toList()); } } diff --git a/repository/src/main/java/tech/ydb/yoj/repository/db/EntityIdSchema.java b/repository/src/main/java/tech/ydb/yoj/repository/db/EntityIdSchema.java index 32a7e217..f0e56f4a 100644 --- a/repository/src/main/java/tech/ydb/yoj/repository/db/EntityIdSchema.java +++ b/repository/src/main/java/tech/ydb/yoj/repository/db/EntityIdSchema.java @@ -58,8 +58,11 @@ public static > Comparator> getIdComparator(Cla STRING, INTEGER, ENUM, BOOLEAN, TIMESTAMP, UUID, BYTE_ARRAY ); + private final EntitySchema entitySchema; + private > EntityIdSchema(EntitySchema entitySchema) { super(entitySchema, ID_FIELD_NAME); + this.entitySchema = entitySchema; var flattenedFields = flattenFields(); @@ -160,6 +163,50 @@ public static boolean isIdFieldName(@NonNull String name) { return name.equals(ID_FIELD_NAME) || name.startsWith(ID_SUBFIELD_NAME_PREFIX); } + public EntitySchema getEntitySchema() { + return entitySchema; + } + + public Class> getEntityType() { + return entitySchema.getType(); + } + + /** + * Creates a new {@link Range} representing all the IDs that have the ID prefix specified in {@code cells}. + * If {@code cells} do not contain any ID fields at all, a whole-table {@code Range} will be returned. + * + * @param cells ID prefix value map: {@link JavaField#getName() ID field name} -> ID field value + * @return {@code Range} for the specified ID prefix + * @throws IllegalArgumentException {@code cells} contain ID values but these do not represent an ID prefix. + *
E.g., for a four-column ID {@code (id_a, id_b, id_c, id_d)} + * the cells contain {@code (id_a, id_c)} but {@code id_b} is missing. + * @see Range#create(EntityIdSchema, Entity.Id) + * @see #newRangeInstance(Map, Map) + * @see #newInstance(Map) + */ + public Range newRangeInstance(Map cells) { + return Range.internalCreate(this, cells); + } + + /** + * Creates a new {@link Range} representing all the IDs between the two specified {@code cells}. + * If {@code cells} do not contain any ID fields at all, a whole-table {@code Range} will be returned. + * + * @param minCellsInclusive min (inclusive) ID value map: {@link JavaField#getName() ID field name} -> + * ID field value + * @param maxCellsInclusive max (inclusive) ID value map: {@link JavaField#getName() ID field name} -> + * ID field value + * @return {@code Range} between the specified minimum and maximum ID values, inclusive + * @throws IllegalArgumentException ID represented by {@code minCellsInclusive} is greater than the ID + * represented by {@code maxCellsInclusive} + * @see Range#create(EntityIdSchema, Entity.Id, Entity.Id) + * @see #newRangeInstance(Map) + * @see #newInstance(Map) + */ + public Range newRangeInstance(Map minCellsInclusive, Map maxCellsInclusive) { + return Range.internalCreate(this, minCellsInclusive, maxCellsInclusive); + } + @Override public int compare(@NonNull ID a, @NonNull ID b) { Map idA = flatten(a); diff --git a/repository/src/main/java/tech/ydb/yoj/repository/db/Range.java b/repository/src/main/java/tech/ydb/yoj/repository/db/Range.java index 700a02e2..12432c81 100644 --- a/repository/src/main/java/tech/ydb/yoj/repository/db/Range.java +++ b/repository/src/main/java/tech/ydb/yoj/repository/db/Range.java @@ -2,76 +2,167 @@ import com.google.common.base.Preconditions; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.NonNull; +import lombok.RequiredArgsConstructor; import lombok.Value; +import tech.ydb.yoj.InternalApi; +import tech.ydb.yoj.databind.schema.Schema.JavaField; +import tech.ydb.yoj.util.lang.Types; import java.util.ArrayList; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.stream.Stream; - -import static java.util.stream.Collectors.toList; +/** + * Represents a range of {@link Entity.Id Entity IDs}. + *
    + *
  • To create a range from entity IDs, use the {@code Range.create()} methods.
  • + *
  • To create a range from entity IDs' column values, use the {@code EntityIdSchema.newRangeInstance()} methods.
  • + *
+ *
+ * + *

Note that the {@code Range} class has some serious limitations: + *

    + *
  • It's not possible to represent an open or a half-open interval of IDs (with one of ID bounds, or both, strictly + * greater/less than the specified value). Use {@code Table.query()} instead for this purpose, e.g.: + * {@code query().where("id.a").gt(...).and("id.a").lt(...).find()}.
  • + *
  • {@code Range} cannot be disjoint: it always represents a monotonically increasing sequence of entity ID values. + * Use {@code Table.find(Set)} to query for multiple ID prefixes (or multiple unordered exact IDs) at once.
  • + *
  • Using {@code Range} for ID prefixes requires the entity ID class to have nullable fields, because + * all prefix ID fields are required to have a non-{@code null} value, and all non-prefix ID fields are required + * to be {@code null}. If the ID class absolutely cannot have nullable fields, use {@code Table.query()} instead, + * e.g.: {@code query().where("id.prefixField1").eq(...).and("id.prefixField2").eq(...).find()}, though + * this might be a bit less efficient, because the prefix values won't become a tuple in the YDB query.
  • + *
+ * + * @param Entity ID type + */ @Value -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class Range> { EntityIdSchema type; - Map eqMap; - Map minMap; - Map maxMap; + Map eqMap; + Map minMap; + Map maxMap; + /** + * Creates a new {@link Range} representing all the IDs that have the same ID prefix specified by {@code partial} + * (with "any ID field value" indicated by {@code null} value of a field in {@code partial} ID). + *
A default entity ID schema provided by {@link EntityIdSchema#of(Class) EntityIdSchema.of(partial.getClass())} + * is assumed. + * + * @param Entity ID type + * @param partial Partial ID, representing an ID prefix (with prefix fields being non-{@code null} and all + * other ID fields set to {@code null}) + * @return {@code Range} for the specified ID prefix + * @throws IllegalArgumentException {@code partial} does not represent an ID prefix. + *
E.g., for a four-column ID {@code (a, b, c, d)}, a + * {@code partial} ID with only {@code a != null} and {@code c != null} + * will be invalid because {@code b} must also be {@code != null}. + * @see Range#create(EntityIdSchema, Entity.Id) + * @see Range#create(Entity.Id, Entity.Id) + */ public static > Range create(@NonNull ID partial) { return create(partial, partial); } + /** + * Creates a new {@link Range} representing all the IDs between the two specified IDs, inclusive. + *
A default entity ID schema provided by {@link EntityIdSchema#of(Class)} is assumed. + * + * @param Entity ID type + * @param minInclusive min (inclusive) ID + * @param maxInclusive max (inclusive) ID + * @return {@code Range} between the specified minimum and maximum IDs, inclusive + * @throws IllegalArgumentException The {@code minInclusive} ID is greater than the {@code maxInclusive} ID + * @see Range#create(EntityIdSchema, Entity.Id, Entity.Id) + * @see EntityIdSchema#newRangeInstance(Map, Map) + */ @SuppressWarnings("unchecked") - public static > Range create(@NonNull ID min, @NonNull ID max) { - return create((EntityIdSchema) EntityIdSchema.of(min.getClass()), min, max); + public static > Range create(@NonNull ID minInclusive, @NonNull ID maxInclusive) { + return create((EntityIdSchema) EntityIdSchema.of(minInclusive.getClass()), minInclusive, maxInclusive); } + /** + * Creates a new {@link Range} representing all the IDs that have the same ID prefix specified by {@code partial} + * (with "any ID field value" indicated by {@code null} value of a field in {@code partial} ID). + * + * @param Entity ID type + * @param type Entity ID schema to use + * @param partial Partial ID, representing an ID prefix (with prefix fields being non-{@code null} and all + * other ID fields set to {@code null}) + * @return {@code Range} for the specified ID prefix + * @throws IllegalArgumentException {@code partial} does not represent an ID prefix. + *
E.g., for a four-column ID {@code (a, b, c, d)}, a + * {@code partial} ID with only {@code a != null} and {@code c != null} + * will be invalid because {@code b} must also be {@code != null}. + * @see Range#create(EntityIdSchema, Entity.Id, Entity.Id) + * @see EntityIdSchema#newRangeInstance(Map) + */ public static > Range create(@NonNull EntityIdSchema type, @NonNull ID partial) { - return create(type, type.flatten(partial)); + return internalCreate(type, type.flatten(partial)); } - public static > Range create(@NonNull EntityIdSchema type, @NonNull ID min, @NonNull ID max) { - Preconditions.checkArgument(min.getClass().equals(max.getClass()), "Min and max must be instances of the same class"); - return create(type, type.flatten(min), type.flatten(max)); + /** + * Creates a new {@link Range} representing all the IDs between the two specified IDs, inclusive. + * + * @param Entity ID type + * @param type Entity ID schema to use + * @param minInclusive min (inclusive) ID + * @param maxInclusive max (inclusive) ID + * @return {@code Range} between the specified minimum and maximum IDs, inclusive + * @throws IllegalArgumentException The {@code minInclusive} ID is greater than the {@code maxInclusive} ID + * @see EntityIdSchema#newRangeInstance(Map, Map) + */ + public static > Range create( + @NonNull EntityIdSchema type, + @NonNull ID minInclusive, + @NonNull ID maxInclusive + ) { + Preconditions.checkArgument(minInclusive.getClass().equals(maxInclusive.getClass()), + "ID bounds must be instances of the same class, but got minInclusive: <%s> and maxInclusive: <%s>", + minInclusive.getClass(), maxInclusive.getClass()); + return internalCreate(type, type.flatten(minInclusive), type.flatten(maxInclusive)); } - public static > Range create(@NonNull EntityIdSchema type, @NonNull Map eqMap) { - return create(type, eqMap, eqMap); + /*package*/ + static > Range internalCreate( + @NonNull EntityIdSchema type, + @NonNull Map idPrefix + ) { + return internalCreate(type, idPrefix, idPrefix); } @SuppressWarnings({"unchecked", "rawtypes"}) - public static > Range create( - EntityIdSchema type, Map mn, Map mx + /*package*/ static > Range internalCreate( + EntityIdSchema type, + Map mn, + Map mx ) { - Map eqMap = new HashMap<>(); - Map minMap = new HashMap<>(); - Map maxMap = new HashMap<>(); + Map eqMap = new LinkedHashMap<>(); + Map minMap = new LinkedHashMap<>(); + Map maxMap = new LinkedHashMap<>(); StringBuilder s = new StringBuilder(); - for (String fn : type.flattenFieldNames()) { - Comparable a = (Comparable) mn.get(fn); - Comparable b = (Comparable) mx.get(fn); + for (JavaField jf : type.flattenFields()) { + Comparable a = (Comparable) mn.get(jf.getName()); + Comparable b = (Comparable) mx.get(jf.getName()); if (a == null && b == null) { s.append("0"); } else if (a == null) { - maxMap.put(fn, b); + maxMap.put(jf, b); s.append("<"); } else if (b == null) { - minMap.put(fn, a); + minMap.put(jf, a); s.append("<"); } else if (a.compareTo(b) < 0) { - minMap.put(fn, a); - maxMap.put(fn, b); + minMap.put(jf, a); + maxMap.put(jf, b); s.append("<"); } else if (a.compareTo(b) == 0) { s.append("="); - eqMap.put(fn, a); + eqMap.put(jf, a); } else { throw new IllegalArgumentException("min must be less or equal to max"); } @@ -82,23 +173,45 @@ public static > Range create( return new Range<>(type, eqMap, minMap, maxMap); } + @InternalApi + public Map getEqMap() { + return eqMap; + } + + @InternalApi + public Map getMaxMap() { + return maxMap; + } + + @InternalApi + public Map getMinMap() { + return minMap; + } + + /** + * Checks whether the specified ID is contained within this {@code Range}. + * + * @param id ID to check. Must not be partial (with ID prefix fields set + * to non-{@code null} values and all other ID fields set to {@code null}) + * @return {@code true} if the specified {@code id} is in Range; {@code false} otherwise + * @throws IllegalArgumentException {@code id} is partial + */ @SuppressWarnings({"unchecked", "rawtypes"}) - public boolean contains(ID id) { + public boolean contains(@NonNull ID id) { Map flat = type.flatten(id); - for (String fn : type.flattenFieldNames()) { - Comparable c = (Comparable) flat.get(fn); - if (c == null) { - throw new IllegalArgumentException("Id fields cannot be null: " + id); - } - Object x = eqMap.get(fn); + for (JavaField jf : type.flattenFields()) { + Comparable c = (Comparable) flat.get(jf.getName()); + Preconditions.checkArgument(c != null, "ID fields cannot be null, but got %s=null in %s", jf.getPath(), id); + + Object x = eqMap.get(jf); if (x != null && c.compareTo(x) != 0) { return false; } - Object a = minMap.get(fn); + Object a = minMap.get(jf); if (a != null && c.compareTo(a) < 0) { return false; } - Object b = maxMap.get(fn); + Object b = maxMap.get(jf); if (b != null && c.compareTo(b) > 0) { return false; } @@ -106,21 +219,15 @@ public boolean contains(ID id) { return true; } - public List> getSchema() { - return Stream.of(eqMap.keySet(), minMap.keySet(), maxMap.keySet()) - .collect(toList()); - } - @Override public String toString() { List list = new ArrayList<>(); - eqMap.forEach((k, v) -> list.add(k + "=" + v)); - minMap.forEach((k, v) -> list.add(k + ">=" + v)); - maxMap.forEach((k, v) -> list.add(k + "<=" + v)); - return "Range(" - + type.getType().getName().replaceFirst(".*\\.", "") - + ": " - + String.join(", ", list) - + ")"; + eqMap.forEach((k, v) -> list.add(k.getPath() + " == " + v)); + minMap.forEach((k, v) -> list.add(k.getPath() + " >= " + v)); + maxMap.forEach((k, v) -> list.add(k.getPath() + " <= " + v)); + + String entityTypeName = Types.getShortTypeName(type.getEntityType()); + return "Range[" + entityTypeName + "](" + + (list.isEmpty() ? "*" : String.join(" && ", list)) + ")"; } }