diff --git a/databind/src/main/java/tech/ydb/yoj/databind/converter/NotNullColumn.java b/databind/src/main/java/tech/ydb/yoj/databind/converter/NotNullColumn.java
new file mode 100644
index 00000000..e9cc3814
--- /dev/null
+++ b/databind/src/main/java/tech/ydb/yoj/databind/converter/NotNullColumn.java
@@ -0,0 +1,22 @@
+package tech.ydb.yoj.databind.converter;
+
+import tech.ydb.yoj.databind.schema.Column;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.RECORD_COMPONENT;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+/**
+ * Signifies that the column stored in the database does not accept {@code NULL} values.
+ *
+ * @see Column#notNull
+ */
+@Column(notNull = true)
+@Target({FIELD, RECORD_COMPONENT, ANNOTATION_TYPE})
+@Retention(RUNTIME)
+public @interface NotNullColumn {
+}
diff --git a/databind/src/main/java/tech/ydb/yoj/databind/schema/Column.java b/databind/src/main/java/tech/ydb/yoj/databind/schema/Column.java
index c8dbe0c4..034291e9 100644
--- a/databind/src/main/java/tech/ydb/yoj/databind/schema/Column.java
+++ b/databind/src/main/java/tech/ydb/yoj/databind/schema/Column.java
@@ -58,6 +58,18 @@
*/
String dbTypeQualifier() default "";
+ /**
+ * Specifies whether only non-{@code NULL} values can be stored in this column. Defaults to {@code false} (allow {@code NULL} values).
+ * Note that this is orthogonal to Java nullness annotations, because YOJ uses {@code null} values for ID fields as a convention
+ * for "range over all possible values of this ID field" (see {@code Entity.Id.isPartial()}).
+ *
Tip: Use the {@link tech.ydb.yoj.databind.converter.NotNullColumn} annotation if you only need to overide
+ * {@code Column.notNull} to {@code true}.
+ *
+ * @see #149
+ */
+ @ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/149")
+ boolean notNull() default false;
+
/**
* Determines whether the {@link FieldValueType#COMPOSITE composite field} will be:
*
diff --git a/databind/src/main/java/tech/ydb/yoj/databind/schema/Schema.java b/databind/src/main/java/tech/ydb/yoj/databind/schema/Schema.java
index 9e3e7717..c5801485 100644
--- a/databind/src/main/java/tech/ydb/yoj/databind/schema/Schema.java
+++ b/databind/src/main/java/tech/ydb/yoj/databind/schema/Schema.java
@@ -127,7 +127,15 @@ protected Schema(JavaField subSchemaField, @Nullable NamingStrategy parentNaming
this.fields = subSchemaField.fields.stream().map(this::newRootJavaField).toList();
} else {
if (subSchemaField.getCustomValueTypeInfo() != null) {
- var dummyField = new JavaField(new DummyCustomValueSubField(subSchemaField), subSchemaField, __ -> true);
+ // Even if custom value type is a record/POJO/... that contains subfields, we treat it as a flat single-column value
+ // because that's what a custom value type's ValueConverter returns: a single value fit for a database column.
+ // (Remember, we do not allow ValueConverter.toColumn() to return a COMPOSITE value or a value of a custom value type)
+ var dummyField = new JavaField(
+ new DummyCustomValueSubField(subSchemaField),
+ subSchemaField,
+ __ -> true,
+ this::isRequiredField
+ );
dummyField.setName(subSchemaField.getName());
this.fields = List.of(dummyField);
} else {
@@ -187,11 +195,11 @@ name, getType(), fieldPath)
columns.add(field.getName());
}
outputIndexes.add(Index.builder()
- .indexName(name)
- .fieldNames(List.copyOf(columns))
- .unique(index.type() == GlobalIndex.Type.UNIQUE)
- .async(index.type() == GlobalIndex.Type.GLOBAL_ASYNC)
- .build());
+ .indexName(name)
+ .fieldNames(List.copyOf(columns))
+ .unique(index.type() == GlobalIndex.Type.UNIQUE)
+ .async(index.type() == GlobalIndex.Type.GLOBAL_ASYNC)
+ .build());
}
return outputIndexes;
}
@@ -249,7 +257,7 @@ private static List collectChangefeeds(
}
private JavaField newRootJavaField(@NonNull ReflectField field) {
- return new JavaField(field, null, this::isFlattenable);
+ return new JavaField(field, null, this::isFlattenable, this::isRequiredField);
}
private JavaField newRootJavaField(@NonNull JavaField javaField) {
@@ -288,6 +296,19 @@ protected boolean isFlattenable(ReflectField field) {
return false;
}
+ /**
+ * @param field field
+ * @return {@code true} if this field is required; {@code false} otherwise
+ */
+ @ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/149")
+ protected boolean isRequiredField(ReflectField field) {
+ var column = field.getColumn();
+ if (column != null) {
+ return column.notNull();
+ }
+ return false;
+ }
+
public final Class getType() {
return type;
}
@@ -492,6 +513,9 @@ public static final class JavaField {
private final FieldValueType valueType;
@Getter
private final boolean flattenable;
+
+ private final boolean required;
+
@Getter
private String name;
@Getter
@@ -499,15 +523,18 @@ public static final class JavaField {
private final List fields;
- private JavaField(ReflectField field, JavaField parent, Predicate isFlattenable) {
+ private JavaField(ReflectField field, JavaField parent, Predicate isFlattenable, Predicate isRequired) {
this.field = field;
this.parent = parent;
this.flattenable = isFlattenable.test(field);
+
+ this.required = (parent != null && parent.required) || isRequired.test(field);
+
this.path = parent == null ? field.getName() : parent.getPath() + PATH_DELIMITER + field.getName();
this.valueType = field.getValueType();
if (valueType.isComposite()) {
this.fields = field.getChildren().stream()
- .map(f -> new JavaField(f, this, isFlattenable))
+ .map(f -> new JavaField(f, this, isFlattenable, isRequired))
.toList();
if (flattenable && isFlat()) {
@@ -522,6 +549,7 @@ private JavaField(JavaField javaField, JavaField parent) {
this.field = javaField.field;
this.parent = parent;
this.flattenable = javaField.flattenable;
+ this.required = javaField.required;
this.name = javaField.name;
this.path = javaField.path;
this.valueType = javaField.valueType;
@@ -536,7 +564,7 @@ private JavaField(JavaField javaField, JavaField parent) {
* If the {@link Column} annotation is present, the field {@code dbType} may be used to
* specify the DB column type.
*
- * @return the DB column type for data binding if specified, {@code null} otherwise
+ * @return the DB column type for data binding if specified, {@link DbType#DEFAULT} otherwise
* @see Column
*/
public DbType getDbType() {
@@ -783,6 +811,27 @@ public > CustomValueTypeInfo getCustomV
return (CustomValueTypeInfo) field.getCustomValueTypeInfo();
}
+ /**
+ * @return {@code true} if the database column does accept {@code NULL}; {@code false} otherwise
+ * @see Column#notNull()
+ * @see #isRequired()
+ * @see #149
+ */
+ @ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/149")
+ public boolean isOptional() {
+ return !required;
+ }
+
+ /**
+ * @return {@code true} if the database column does not accept {@code NULL}; {@code false} otherwise
+ * @see Column#notNull()
+ * @see #149
+ */
+ @ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/149")
+ public boolean isRequired() {
+ return required;
+ }
+
@Override
public String toString() {
return getType().getTypeName() + " " + field.getName();
diff --git a/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/BadMetaAnnotatedEntity.java b/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/BadMetaAnnotatedEntity.java
new file mode 100644
index 00000000..130e3f7c
--- /dev/null
+++ b/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/BadMetaAnnotatedEntity.java
@@ -0,0 +1,18 @@
+package tech.ydb.yoj.databind.schema.naming;
+
+import tech.ydb.yoj.databind.converter.NotNullColumn;
+import tech.ydb.yoj.databind.converter.ObjectColumn;
+
+public record BadMetaAnnotatedEntity(
+ Id id,
+
+ @ObjectColumn
+ @NotNullColumn
+ Key key
+) {
+ public record Key(String parent, long timestamp) {
+ }
+
+ public record Id(String value) {
+ }
+}
diff --git a/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/MetaAnnotatedEntity.java b/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/MetaAnnotatedEntity.java
new file mode 100644
index 00000000..e3590f22
--- /dev/null
+++ b/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/MetaAnnotatedEntity.java
@@ -0,0 +1,18 @@
+package tech.ydb.yoj.databind.schema.naming;
+
+import tech.ydb.yoj.databind.converter.NotNullColumn;
+import tech.ydb.yoj.databind.converter.ObjectColumn;
+
+public record MetaAnnotatedEntity(
+ @NotNullColumn
+ Id id,
+
+ @ObjectColumn
+ Key key
+) {
+ public record Key(String parent, long timestamp) {
+ }
+
+ public record Id(String value) {
+ }
+}
diff --git a/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/MetaAnnotationTest.java b/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/MetaAnnotationTest.java
new file mode 100644
index 00000000..4eddb9a4
--- /dev/null
+++ b/databind/src/test/java/tech/ydb/yoj/databind/schema/naming/MetaAnnotationTest.java
@@ -0,0 +1,36 @@
+package tech.ydb.yoj.databind.schema.naming;
+
+import org.junit.Test;
+import tech.ydb.yoj.databind.FieldValueType;
+import tech.ydb.yoj.databind.schema.ObjectSchema;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+public class MetaAnnotationTest {
+ @Test
+ public void basicMetaAnnotation() {
+ var schema = ObjectSchema.of(MetaAnnotatedEntity.class);
+
+ var idField = schema.getField("id");
+ assertThat(idField.isRequired()).isTrue();
+
+ var keyField = schema.getField("key");
+ assertThat(keyField.getValueType()).isEqualTo(FieldValueType.OBJECT);
+
+ var idColumn = idField.getField().getColumn();
+ assertThat(idColumn).isNotNull();
+ assertThat(idColumn.flatten()).isTrue();
+ assertThat(idColumn.notNull()).isTrue();
+
+ var keyColumn = keyField.getField().getColumn();
+ assertThat(keyColumn).isNotNull();
+ assertThat(keyColumn.flatten()).isFalse();
+ assertThat(keyColumn.notNull()).isFalse();
+ }
+
+ @Test
+ public void multipleAnnotationsNotAllowed() {
+ assertThatIllegalArgumentException().isThrownBy(() -> ObjectSchema.of(BadMetaAnnotatedEntity.class));
+ }
+}
diff --git a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/client/YdbSchemaOperations.java b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/client/YdbSchemaOperations.java
index 2a806983..d34ca9ec 100644
--- a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/client/YdbSchemaOperations.java
+++ b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/client/YdbSchemaOperations.java
@@ -100,7 +100,11 @@ public void createTable(String name, List columns, List<
.orElseThrow(() -> new CreateTableException(String.format("Can't create table '%s'%n"
+ "Can't find yql primitive type '%s' in YDB SDK", name, yqlType)));
ValueProtos.Type typeProto = ValueProtos.Type.newBuilder().setTypeId(yqlType).build();
- builder.addNullableColumn(c.getName(), YdbConverter.convertProtoPrimitiveTypeToSDK(typeProto));
+ if (c.isOptional()) {
+ builder.addNullableColumn(c.getName(), YdbConverter.convertProtoPrimitiveTypeToSDK(typeProto));
+ } else {
+ builder.addNonnullColumn(c.getName(), YdbConverter.convertProtoPrimitiveTypeToSDK(typeProto));
+ }
});
List primaryKeysNames = primaryKeys.stream().map(Schema.JavaField::getName).collect(toList());
builder.setPrimaryKeys(primaryKeysNames);
@@ -220,8 +224,9 @@ public Table describeTable(String name, List columns, Li
.map(c -> {
String columnName = c.getName();
String simpleType = YqlType.of(c).getYqlType().name();
+ boolean isNotNull = c.isRequired();
boolean isPrimaryKey = primaryKeysNames.contains(columnName);
- return new Column(columnName, simpleType, isPrimaryKey);
+ return new Column(columnName, simpleType, isPrimaryKey, isNotNull);
})
.toList();
List ydbIndexes = indexes.stream()
@@ -339,8 +344,9 @@ private Table describeTableInternal(String path) {
.map(c -> {
String columnName = c.getName();
String simpleType = safeUnwrapOptional(c.getType()).toPb().getTypeId().name();
+ boolean isNotNull = isNotNull(c.getType());
boolean isPrimaryKey = table.getPrimaryKeys().contains(columnName);
- return new Column(columnName, simpleType, isPrimaryKey);
+ return new Column(columnName, simpleType, isPrimaryKey, isNotNull);
})
.toList(),
table.getIndexes().stream()
@@ -356,6 +362,17 @@ private Type safeUnwrapOptional(Type type) {
return type.getKind() == Type.Kind.OPTIONAL ? type.unwrapOptional() : type;
}
+ private boolean isNotNull(Type type) {
+ if (type.getKind() == Type.Kind.VOID || type.getKind() == Type.Kind.NULL) {
+ // This should never happen: Both Void and Null type can only have NULL as their value, having such columns is pointless.
+ throw new IllegalStateException("Void and Null types should never be used for columns");
+ }
+
+ // Optional<...> explicitly allows for NULL, other kinds should be NOT NULL by default
+ // (incl. Lists, Structs, Tuples, Variants are not supported as columns (yet?) but they can be...)
+ return type.getKind() != Type.Kind.OPTIONAL;
+ }
+
public void removeTablespace() {
removeTablespace(tablespace);
}
@@ -478,6 +495,7 @@ public static class Column {
String name;
String type;
boolean primary;
+ boolean notNull;
}
@Value
diff --git a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/compatibility/YdbSchemaCompatibilityChecker.java b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/compatibility/YdbSchemaCompatibilityChecker.java
index 4c35042a..b8f2de5f 100644
--- a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/compatibility/YdbSchemaCompatibilityChecker.java
+++ b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/compatibility/YdbSchemaCompatibilityChecker.java
@@ -274,6 +274,11 @@ private String makeDropColumn(YdbSchemaOperations.Table table, YdbSchemaOperatio
}
private String makeAddColumn(YdbSchemaOperations.Table table, YdbSchemaOperations.Column c) {
+ if (c.isNotNull()) {
+ throw new IllegalArgumentException("Trying to add a NOT NULL column `" + c.getName() + "` but YDB does not support adding "
+ + "NOT NULL columns to existing tables, even with a DEFAULT value");
+ }
+
if (config.useBuilderDDLSyntax) {
return "DDLQuery.addColumn(" + builderDDLTableNameLiteral(table) + ", " +
javaLiteral(c.getName()) + ", " +
@@ -302,7 +307,7 @@ private static String builderDDLIndexes(YdbSchemaOperations.Table table) {
private static String builderDDLColumns(YdbSchemaOperations.Table table) {
return table.getColumns().stream()
- .map(c -> "\t\t.addNullableColumn(" + javaLiteral(c.getName()) + ", " +
+ .map(c -> "\t\t.add" + (c.isNotNull() ? "NotNull" : "Nullable") + "Column(" + javaLiteral(c.getName()) + ", " +
typeToDDL(c.getType()) + ")\n")
.collect(joining(""));
}
@@ -335,7 +340,7 @@ private static String typeToDDL(String type) {
private static String columns(YdbSchemaOperations.Table table) {
return table.getColumns().stream()
- .map(c -> "\t`" + c.getName() + "` " + c.getType())
+ .map(c -> "\t`" + c.getName() + "` " + c.getType() + (c.isNotNull() ? " NOT NULL" : ""))
.collect(joining(",\n"));
}
@@ -352,13 +357,13 @@ private static String indexes(YdbSchemaOperations.Table table) {
return "\n";
}
return ",\n" + indexes.stream()
- .map(idx -> "\t" + indexStatement(idx))
- .collect(Collectors.joining(",\n")) + "\n";
+ .map(idx -> "\t" + indexStatement(idx))
+ .collect(Collectors.joining(",\n")) + "\n";
}
private static String indexStatement(YdbSchemaOperations.Index idx) {
return String.format("INDEX `%s` GLOBAL %sON (%s)",
- idx.getName(), idx.isUnique() ? "UNIQUE " : idx.isAsync() ? "ASYNC " : "", indexColumns(idx.getColumns()));
+ idx.getName(), idx.isUnique() ? "UNIQUE " : idx.isAsync() ? "ASYNC " : "", indexColumns(idx.getColumns()));
}
private static String indexColumns(List columns) {
@@ -413,7 +418,7 @@ private void makeMigrationTableIndexInstructions(YdbSchemaOperations.Table from,
.collect(toMap(YdbSchemaOperations.Index::getName, Function.identity()));
Function createIndex = i ->
- String.format("ALTER TABLE `%s` ADD %s;", to.getName(), indexStatement(i));
+ String.format("ALTER TABLE `%s` ADD %s;", to.getName(), indexStatement(i));
Function dropIndex = i ->
String.format("ALTER TABLE `%s` DROP INDEX `%s`;", from.getName(), i.getName());
@@ -461,9 +466,16 @@ private String columnDiff(YdbSchemaOperations.Column column, YdbSchemaOperations
if (column.isPrimary() != newColumn.isPrimary()) {
return "primary_key changed: " + column.isPrimary() + " --> " + newColumn.isPrimary();
}
+ if (column.isNotNull() != newColumn.isNotNull()) {
+ return "nullability changed: " + nullabilityStr(column) + " --> " + nullabilityStr(newColumn);
+ }
return "type changed: " + column.getType() + " --> " + newColumn.getType();
}
+ private String nullabilityStr(YdbSchemaOperations.Column column) {
+ return column.isNotNull() ? "NOT NULL" : "NULL";
+ }
+
private boolean containsPrefix(String globalName, Set prefixes) {
if (prefixes.isEmpty()) {
return false;
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..eb72407e 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
@@ -83,20 +83,23 @@ enum RangeBound {
EQ("=", Range::getEqMap),
MAX("<=", Range::getMaxMap),
MIN(">=", Range::getMinMap);
- String op;
- Function> mapper;
+ private final String op;
+ private final Function, Map> mapper;
- public Map map(Range range) {
+ public Map map(Range> range) {
return mapper.apply(range);
}
}
- static class YqlStatementRangeParam extends YqlStatementParam {
+ private static class YqlStatementRangeParam extends YqlStatementParam {
private final RangeBound rangeBound;
private final String rangeName;
YqlStatementRangeParam(YqlType type, String name, RangeBound rangeBound) {
- super(type, rangeBound.name() + "_" + name, true);
+ // YqlStatementRangeParam is always about the value of ID field,
+ // and YOJ disallows writing NULL to columns corresponding to ID fields.
+ // ==> YqlStatementRangeParams are always required, never optional
+ super(type, rangeBound.name() + "_" + name, false);
this.rangeBound = rangeBound;
this.rangeName = name;
}
diff --git a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/MultipleVarsYqlStatement.java b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/MultipleVarsYqlStatement.java
index 81d132e6..e9e72c02 100644
--- a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/MultipleVarsYqlStatement.java
+++ b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/MultipleVarsYqlStatement.java
@@ -53,7 +53,9 @@ public List getParams() {
}
private static boolean isOptional(Schema.JavaField f) {
- return !isIdField(f);
+ // TODO(nvamelichev): When we consider all ID fields required by default (see EntityIdFieldNullability), the isIdField(f) check should be removed
+ boolean required = isIdField(f) || f.isRequired();
+ return !required;
}
@Override
diff --git a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/UpdateSetParam.java b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/UpdateSetParam.java
index 833bcd18..2eb60743 100644
--- a/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/UpdateSetParam.java
+++ b/repository-ydb-v2/src/main/java/tech/ydb/yoj/repository/ydb/statement/UpdateSetParam.java
@@ -28,7 +28,7 @@ public final class UpdateSetParam extends PredicateStatement.Param {
private UpdateSetParam(@NonNull EntitySchema.JavaField field,
@NonNull EntitySchema.JavaField rootField, @NonNull String rootFieldPath) {
- super(YqlType.of(field), PREFIX + underscoreIllegalSymbols(field.getName()), true);
+ super(YqlType.of(field), PREFIX + underscoreIllegalSymbols(field.getName()), field.isOptional());
Preconditions.checkState(field.isFlat(), "Can only create update statements for flat fields");
this.field = field;
diff --git a/repository-ydb-v2/src/test/java/tech/ydb/yoj/repository/ydb/YdbRepositoryIntegrationTest.java b/repository-ydb-v2/src/test/java/tech/ydb/yoj/repository/ydb/YdbRepositoryIntegrationTest.java
index 483ea996..5c000f1a 100644
--- a/repository-ydb-v2/src/test/java/tech/ydb/yoj/repository/ydb/YdbRepositoryIntegrationTest.java
+++ b/repository-ydb-v2/src/test/java/tech/ydb/yoj/repository/ydb/YdbRepositoryIntegrationTest.java
@@ -83,6 +83,7 @@
import tech.ydb.yoj.repository.ydb.model.IndexedEntityCreateIndex;
import tech.ydb.yoj.repository.ydb.model.IndexedEntityDropIndex;
import tech.ydb.yoj.repository.ydb.model.IndexedEntityNew;
+import tech.ydb.yoj.repository.ydb.model.IndexedEntityNotNull;
import tech.ydb.yoj.repository.ydb.sample.model.HintAutoPartitioningByLoad;
import tech.ydb.yoj.repository.ydb.sample.model.HintInt64Range;
import tech.ydb.yoj.repository.ydb.sample.model.HintTablePreset;
@@ -112,7 +113,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
-import static org.junit.Assert.assertEquals;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static tech.ydb.yoj.repository.db.EntityExpressions.newFilterBuilder;
import static tech.ydb.yoj.repository.db.EntityExpressions.newOrderBuilder;
@@ -241,9 +242,11 @@ public QueryType getQueryType() {
return QueryType.SELECT;
}
}, null);
- assertEquals(List.of(new GroupByResult("id_yql_list", List.of("id_yql_list"),
+ assertThat(result).containsExactlyElementsOf(List.of(
+ new GroupByResult("id_yql_list", List.of("id_yql_list"),
Map.of("name", "id_yql_list"),
- new GroupByResult.Struct("id_yql_list"))), result);
+ new GroupByResult.Struct("id_yql_list")))
+ );
});
}
@@ -423,7 +426,7 @@ public void checkDBSessionBusy() {
@Test
public void subdirTable() {
- Assertions.assertThat(((YdbRepository) repository).getSchemaOperations().getTableNames(true))
+ assertThat(((YdbRepository) repository).getSchemaOperations().getTableNames(true))
.contains("subdir/SubdirEntity");
}
@@ -534,6 +537,11 @@ public void predicateWithMultipleBoxedPayload() {
});
}
+ @Test
+ public void notNullColumns() {
+ // TODO implement test of NOT NULL
+ }
+
@Test
public void testSelectDefault() {
db.tx(() -> db.indexedTable().insert(e1, e2));
@@ -745,10 +753,10 @@ private void testTransactionTakesTimeoutFromGrpcContext(int timeoutMin) {
public void testCompatibilityDropIndex() {
var checker = new YdbSchemaCompatibilityChecker(List.of(IndexedEntityDropIndex.class), (YdbRepository) repository);
checker.run();
- Assertions.assertThat(checker.getShouldExecuteMessages()).isEmpty();
+ assertThat(checker.getShouldExecuteMessages()).isEmpty();
var ts = getRealYdbConfig().getTablespace();
- Assertions.assertThat(checker.getCanExecuteMessages()).containsAnyOf(
+ assertThat(checker.getCanExecuteMessages()).containsAnyOf(
String.format("ALTER TABLE `%stable_with_indexes` DROP INDEX `key_index`;", ts)
);
}
@@ -756,9 +764,9 @@ public void testCompatibilityDropIndex() {
@Test
public void testCompatibilityCreateIndex() {
var checker = new YdbSchemaCompatibilityChecker(List.of(IndexedEntityCreateIndex.class), (YdbRepository) repository);
- Assertions.assertThatThrownBy(checker::run);
+ assertThatThrownBy(checker::run);
var ts = getRealYdbConfig().getTablespace();
- Assertions.assertThat(checker.getShouldExecuteMessages()).containsExactly(
+ assertThat(checker.getShouldExecuteMessages()).containsExactly(
String.format("ALTER TABLE `%stable_with_indexes` ADD INDEX `key2_index` GLOBAL ON (`key_id`,`valueId2`);", ts)
);
}
@@ -766,9 +774,9 @@ public void testCompatibilityCreateIndex() {
@Test
public void testCompatibilityDropTtl() {
var checker = new YdbSchemaCompatibilityChecker(List.of(EntityDropTtl.class), (YdbRepository) repository);
- Assertions.assertThatThrownBy(checker::run);
+ assertThatThrownBy(checker::run);
var ts = getRealYdbConfig().getTablespace();
- Assertions.assertThat(checker.getShouldExecuteMessages()).containsExactly(
+ assertThat(checker.getShouldExecuteMessages()).containsExactly(
String.format("ALTER TABLE `%sTtlEntity` RESET (TTL);", ts)
);
}
@@ -776,9 +784,9 @@ public void testCompatibilityDropTtl() {
@Test
public void testCompatibilityChangeOrCreateTtl() {
var checker = new YdbSchemaCompatibilityChecker(List.of(EntityChangeTtl.class), (YdbRepository) repository);
- Assertions.assertThatThrownBy(checker::run);
+ assertThatThrownBy(checker::run);
var ts = getRealYdbConfig().getTablespace();
- Assertions.assertThat(checker.getShouldExecuteMessages()).containsExactly(
+ assertThat(checker.getShouldExecuteMessages()).containsExactly(
String.format("ALTER TABLE `%sTtlEntity` SET (TTL = Interval(\"PT2H\") ON createdAt);", ts)
);
}
@@ -786,7 +794,7 @@ public void testCompatibilityChangeOrCreateTtl() {
@Test
public void testCompatibilityNewIndexedTable() {
var checker = new YdbSchemaCompatibilityChecker(List.of(IndexedEntityNew.class), (YdbRepository) repository);
- Assertions.assertThatThrownBy(checker::run);
+ assertThatThrownBy(checker::run);
var ts = getRealYdbConfig().getTablespace();
String expected = String.format(
"CREATE TABLE `%snew_table_with_indexes` (\n" +
@@ -801,24 +809,39 @@ public void testCompatibilityNewIndexedTable() {
"\tINDEX `key3_index` GLOBAL ASYNC ON (`key_id`,`value_id`)\n" +
");",
ts);
- Assert.assertEquals(expected, checker.getShouldExecuteMessages().get(0));
- Assertions.assertThat(checker.getShouldExecuteMessages()).containsExactly(
- expected
- );
-
+ assertThat(checker.getShouldExecuteMessages()).containsExactly(expected);
}
@Test
public void testCompatibilityChangeIndex() {
var checker = new YdbSchemaCompatibilityChecker(List.of(IndexedEntityChangeIndex.class), (YdbRepository) repository);
- Assertions.assertThatThrownBy(checker::run);
+ assertThatThrownBy(checker::run);
var ts = getRealYdbConfig().getTablespace();
String message = String.format("Altering index `%stable_with_indexes`.value_index is impossible: " +
"columns are changed: [value_id, valueId2] --> [value_id].\n", ts);
message += String.format("ALTER TABLE `%stable_with_indexes` DROP INDEX `value_index`;\n", ts);
message += String.format("ALTER TABLE `%stable_with_indexes` ADD INDEX `value_index` GLOBAL ON (`value_id`);", ts);
- Assertions.assertThat(checker.getShouldExecuteMessages()).containsExactly(message);
+ assertThat(checker.getShouldExecuteMessages()).containsExactly(message);
+ }
+
+ @Test
+ public void testCompatibilityNotNull() {
+ var checker = new YdbSchemaCompatibilityChecker(List.of(IndexedEntityNotNull.class), (YdbRepository) repository);
+ assertThatThrownBy(checker::run);
+ var ts = getRealYdbConfig().getTablespace();
+ String expected = String.format(
+ "CREATE TABLE `%snew_table_with_indexes` (\n" +
+ "\t`version_id` STRING NOT NULL,\n" +
+ "\t`key_id` STRING,\n" +
+ "\t`value_id` STRING,\n" +
+ "\t`valueId2` STRING,\n" +
+ "\tPRIMARY KEY(`version_id`),\n" +
+ "\tINDEX `key_index` GLOBAL ON (`key_id`),\n" +
+ "\tINDEX `value_index` GLOBAL ON (`value_id`,`valueId2`)\n" +
+ ");",
+ ts);
+ assertThat(checker.getShouldExecuteMessages()).containsExactly(expected);
}
@Test
@@ -838,11 +861,11 @@ private void executeQuery(String expectSqlQuery, List expectRows,
tableDescriptor, schema, schema, parts, false
);
var sqlQuery = statement.getQuery("ts/");
- assertEquals(expectSqlQuery, sqlQuery);
+ assertThat(sqlQuery).isEqualTo(expectSqlQuery);
// Check we use index and query was not failed
var actual = db.tx(() -> ((YdbTable) db.indexedTable()).find(parts));
- assertEquals(expectRows, actual);
+ assertThat(actual).containsExactlyElementsOf(expectRows);
}
private void checkTxRetryableOnRequestError(StatusCodesProtos.StatusIds.StatusCode statusCode) {
diff --git a/repository-ydb-v2/src/test/java/tech/ydb/yoj/repository/ydb/model/IndexedEntityNotNull.java b/repository-ydb-v2/src/test/java/tech/ydb/yoj/repository/ydb/model/IndexedEntityNotNull.java
new file mode 100644
index 00000000..d4219fb6
--- /dev/null
+++ b/repository-ydb-v2/src/test/java/tech/ydb/yoj/repository/ydb/model/IndexedEntityNotNull.java
@@ -0,0 +1,28 @@
+package tech.ydb.yoj.repository.ydb.model;
+
+import lombok.Value;
+import tech.ydb.yoj.databind.schema.Column;
+import tech.ydb.yoj.databind.schema.GlobalIndex;
+import tech.ydb.yoj.databind.schema.Table;
+import tech.ydb.yoj.repository.db.Entity;
+
+@Value
+@GlobalIndex(name = "key_index", fields = {"keyId"})
+@GlobalIndex(name = "value_index", fields = {"valueId", "valueId2"})
+@Table(name = "new_table_with_indexes")
+public class IndexedEntityNotNull implements Entity {
+ @Column(name = "version_id", notNull = true)
+ Id id;
+ @Column(name = "key_id")
+ String keyId;
+ @Column(name = "value_id")
+ String valueId;
+ @Column
+ String valueId2;
+
+ @Value
+ public static class Id implements Entity.Id {
+ @Column(name = "version_id")
+ String versionId;
+ }
+}
diff --git a/repository/src/main/java/tech/ydb/yoj/repository/db/EntityIdFieldNullability.java b/repository/src/main/java/tech/ydb/yoj/repository/db/EntityIdFieldNullability.java
new file mode 100644
index 00000000..1b0012a6
--- /dev/null
+++ b/repository/src/main/java/tech/ydb/yoj/repository/db/EntityIdFieldNullability.java
@@ -0,0 +1,16 @@
+package tech.ydb.yoj.repository.db;
+
+import tech.ydb.yoj.InternalApi;
+
+@InternalApi
+/*package*/ enum EntityIdFieldNullability {
+ ALWAYS_NULL,
+ USE_COLUMN_ANNOTATION,
+ ALWAYS_NOT_NULL;
+
+ private static final String PROP_NOT_NULL_MODE = "tech.ydb.yoj.repository.db.idFieldNullability";
+
+ public static EntityIdFieldNullability get() {
+ return valueOf(System.getProperty(PROP_NOT_NULL_MODE, USE_COLUMN_ANNOTATION.name()));
+ }
+}
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..d09745a5 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
@@ -3,6 +3,7 @@
import com.google.common.base.Preconditions;
import com.google.common.reflect.TypeToken;
import lombok.NonNull;
+import tech.ydb.yoj.ExperimentalApi;
import tech.ydb.yoj.databind.CustomValueTypes;
import tech.ydb.yoj.databind.FieldValueType;
import tech.ydb.yoj.databind.schema.Schema;
@@ -90,6 +91,16 @@ protected boolean isFlattenable(ReflectField field) {
return true;
}
+ @Override
+ @ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/149")
+ protected boolean isRequiredField(ReflectField field) {
+ return switch (EntityIdFieldNullability.get()) {
+ case ALWAYS_NULL -> false;
+ case USE_COLUMN_ANNOTATION -> super.isRequiredField(field);
+ case ALWAYS_NOT_NULL -> true;
+ };
+ }
+
public static , ID extends Entity.Id> EntityIdSchema ofEntity(Class entityType) {
return ofEntity(entityType, null);
}
diff --git a/repository/src/main/java/tech/ydb/yoj/repository/db/EntitySchema.java b/repository/src/main/java/tech/ydb/yoj/repository/db/EntitySchema.java
index 77034fe3..12fbc6e8 100644
--- a/repository/src/main/java/tech/ydb/yoj/repository/db/EntitySchema.java
+++ b/repository/src/main/java/tech/ydb/yoj/repository/db/EntitySchema.java
@@ -2,6 +2,7 @@
import com.google.common.reflect.TypeToken;
import lombok.Getter;
+import tech.ydb.yoj.ExperimentalApi;
import tech.ydb.yoj.databind.schema.Schema;
import tech.ydb.yoj.databind.schema.configuration.SchemaRegistry;
import tech.ydb.yoj.databind.schema.configuration.SchemaRegistry.SchemaKey;
@@ -102,6 +103,20 @@ protected boolean isFlattenable(ReflectField field) {
return Entity.Id.class.isAssignableFrom(field.getType());
}
+ @Override
+ @ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/149")
+ protected boolean isRequiredField(ReflectField field) {
+ if (Entity.Id.class.isAssignableFrom(field.getType())) {
+ return switch (EntityIdFieldNullability.get()) {
+ case ALWAYS_NULL -> false;
+ case USE_COLUMN_ANNOTATION -> super.isRequiredField(field);
+ case ALWAYS_NOT_NULL -> true;
+ };
+ } else {
+ return super.isRequiredField(field);
+ }
+ }
+
public > EntityIdSchema getIdSchema() {
return EntityIdSchema.from(this);
}
diff --git a/repository/src/main/java/tech/ydb/yoj/repository/db/ViewSchema.java b/repository/src/main/java/tech/ydb/yoj/repository/db/ViewSchema.java
index 0d1e5e64..913c1230 100644
--- a/repository/src/main/java/tech/ydb/yoj/repository/db/ViewSchema.java
+++ b/repository/src/main/java/tech/ydb/yoj/repository/db/ViewSchema.java
@@ -1,5 +1,6 @@
package tech.ydb.yoj.repository.db;
+import tech.ydb.yoj.ExperimentalApi;
import tech.ydb.yoj.databind.schema.Schema;
import tech.ydb.yoj.databind.schema.configuration.SchemaRegistry;
import tech.ydb.yoj.databind.schema.configuration.SchemaRegistry.SchemaKey;
@@ -33,4 +34,18 @@ public static ViewSchema of(SchemaRegistry regis
protected boolean isFlattenable(ReflectField field) {
return Entity.Id.class.isAssignableFrom(field.getType());
}
+
+ @Override
+ @ExperimentalApi(issue = "https://github.com/ydb-platform/yoj-project/issues/149")
+ protected boolean isRequiredField(ReflectField field) {
+ if (Entity.Id.class.isAssignableFrom(field.getType())) {
+ return switch (EntityIdFieldNullability.get()) {
+ case ALWAYS_NULL -> false;
+ case USE_COLUMN_ANNOTATION -> super.isRequiredField(field);
+ case ALWAYS_NOT_NULL -> true;
+ };
+ } else {
+ return super.isRequiredField(field);
+ }
+ }
}
diff --git a/repository/src/main/java/tech/ydb/yoj/repository/db/cache/RepositoryCache.java b/repository/src/main/java/tech/ydb/yoj/repository/db/cache/RepositoryCache.java
index c58649e5..d1819826 100644
--- a/repository/src/main/java/tech/ydb/yoj/repository/db/cache/RepositoryCache.java
+++ b/repository/src/main/java/tech/ydb/yoj/repository/db/cache/RepositoryCache.java
@@ -1,13 +1,9 @@
package tech.ydb.yoj.repository.db.cache;
-import com.google.common.base.Preconditions;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.Value;
-import tech.ydb.yoj.DeprecationWarnings;
import tech.ydb.yoj.InternalApi;
-import tech.ydb.yoj.repository.db.Entity;
-import tech.ydb.yoj.repository.db.EntitySchema;
import tech.ydb.yoj.repository.db.TableDescriptor;
import java.util.Optional;
@@ -92,7 +88,7 @@ class Key {
Object id;
/**
- * @deprecated This constructor will start throwing {@code UnsupportedOperationException} in YOJ 2.7.0
+ * @deprecated This constructor always throws {@code UnsupportedOperationException},
* and will be permanently removed in YOJ 3.0.0.
* If your custom {@code YqlStatement}s make use of {@code readFromCache()} and {@code storeToCache()},
* please migrate them to use the new constructors: {@code RepositoryCache.Key(TableDescriptor, Entity.Id)}
@@ -100,19 +96,10 @@ class Key {
* for a more general version suitable for caching e.g. {@code View}s.
*/
@Deprecated(forRemoval = true)
- public Key(@NonNull Class> clazz, @NonNull Object id) {
- DeprecationWarnings.warnOnce("tech.ydb.yoj.repository.db.cache.RepositoryCache.Key(Class>, Object)",
- "Please migrate to RepositoryCache.Key(Class>, TableDescriptor>, Object) constructor");
-
- Preconditions.checkArgument(clazz.isAssignableFrom(Entity.class),
- "Deprecated RepositoryCache.Key(Class>, Object) constructor only entities, but got: %s", clazz);
-
- this.valueType = clazz;
-
- @SuppressWarnings({"unchecked", "rawtypes"}) EntitySchema> schema = EntitySchema.of((Class) clazz);
- this.tableDescriptor = TableDescriptor.from(schema);
-
- this.id = id;
+ public Key(@NonNull Class> ignore1, @NonNull Object ignore2) {
+ throw new UnsupportedOperationException(
+ "Please migrate to RepositoryCache.Key(Class>, TableDescriptor>, Object) constructor"
+ );
}
/**
diff --git a/util/src/main/java/tech/ydb/yoj/util/lang/Annotations.java b/util/src/main/java/tech/ydb/yoj/util/lang/Annotations.java
index 48fdc018..1eda64f0 100644
--- a/util/src/main/java/tech/ydb/yoj/util/lang/Annotations.java
+++ b/util/src/main/java/tech/ydb/yoj/util/lang/Annotations.java
@@ -1,5 +1,7 @@
package tech.ydb.yoj.util.lang;
+import com.google.common.base.Preconditions;
+
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.lang.annotation.Annotation;
@@ -55,11 +57,13 @@ private static Set collectAnnotations(Class> component) {
private static A findInDepth(Class annotation, @Nonnull AnnotatedElement component) {
Set visited = new HashSet<>();
Set annotationToExamine = getAnnotations(component);
+
+ Set found = new HashSet<>();
while (!annotationToExamine.isEmpty()) {
Annotation candidate = annotationToExamine.iterator().next();
if (visited.add(candidate)) {
- if (candidate.annotationType() == annotation) {
- return (A) candidate;
+ if (candidate.annotationType().equals(annotation)) {
+ found.add((A) candidate);
} else {
nonJdkAnnotations(candidate.annotationType().getDeclaredAnnotations())
.forEach(annotationToExamine::add);
@@ -67,7 +71,15 @@ private static A findInDepth(Class annotation, @Nonnul
}
annotationToExamine.remove(candidate);
}
- return null;
+
+ if (found.isEmpty()) {
+ return null;
+ }
+
+ var iter = found.iterator();
+ A firstFound = iter.next();
+ Preconditions.checkArgument(!iter.hasNext(), "Got multiple (meta-)annotations matching %s on '%s'", annotation.getCanonicalName(), component);
+ return firstFound;
}
private static Set getAnnotations(AnnotatedElement component) {
@@ -79,8 +91,7 @@ private static Set getAnnotations(AnnotatedElement component) {
}
private static Stream nonJdkAnnotations(Annotation[] annotations) {
- return Arrays.stream(annotations)
- .filter(da -> !jdkClass(da.annotationType()));
+ return Arrays.stream(annotations).filter(da -> !jdkClass(da.annotationType()));
}
static boolean jdkClass(Class> type) {