Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
}
12 changes: 12 additions & 0 deletions databind/src/main/java/tech/ydb/yoj/databind/schema/Column.java
Original file line number Diff line number Diff line change
Expand Up @@ -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).<br>
* Note that this is orthogonal to Java nullness annotations, because YOJ uses {@code null} values for ID fields as a convention
* for "range over <em>all possible values</em> of this ID field" (see {@code Entity.Id.isPartial()}).
* <p><strong>Tip:</strong> Use the {@link tech.ydb.yoj.databind.converter.NotNullColumn} annotation if you only need to overide
* {@code Column.notNull} to {@code true}.
*
* @see <a href="https://github.com/ydb-platform/yoj-project/issues/149">#149</a>
*/
@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:
* <ul>
Expand Down
69 changes: 59 additions & 10 deletions databind/src/main/java/tech/ydb/yoj/databind/schema/Schema.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -249,7 +257,7 @@ private static List<tech.ydb.yoj.databind.schema.Changefeed> 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) {
Expand Down Expand Up @@ -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<T> getType() {
return type;
}
Expand Down Expand Up @@ -492,22 +513,28 @@ public static final class JavaField {
private final FieldValueType valueType;
@Getter
private final boolean flattenable;

private final boolean required;

@Getter
private String name;
@Getter
private String path;

private final List<JavaField> fields;

private JavaField(ReflectField field, JavaField parent, Predicate<ReflectField> isFlattenable) {
private JavaField(ReflectField field, JavaField parent, Predicate<ReflectField> isFlattenable, Predicate<ReflectField> 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()) {
Expand All @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -783,6 +811,27 @@ public <J, C extends Comparable<? super C>> CustomValueTypeInfo<J, C> getCustomV
return (CustomValueTypeInfo<J, C>) field.getCustomValueTypeInfo();
}

/**
* @return {@code true} if the database column does accept {@code NULL}; {@code false} otherwise
* @see Column#notNull()
* @see #isRequired()
* @see <a href="https://github.com/ydb-platform/yoj-project/issues/149">#149</a>
*/
@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 <a href="https://github.com/ydb-platform/yoj-project/issues/149">#149</a>
*/
@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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ public void createTable(String name, List<EntitySchema.JavaField> 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<String> primaryKeysNames = primaryKeys.stream().map(Schema.JavaField::getName).collect(toList());
builder.setPrimaryKeys(primaryKeysNames);
Expand Down Expand Up @@ -220,8 +224,9 @@ public Table describeTable(String name, List<EntitySchema.JavaField> 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<Index> ydbIndexes = indexes.stream()
Expand Down Expand Up @@ -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()
Expand All @@ -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);
}
Expand Down Expand Up @@ -478,6 +495,7 @@ public static class Column {
String name;
String type;
boolean primary;
boolean notNull;
}

@Value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) + ", " +
Expand Down Expand Up @@ -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(""));
}
Expand Down Expand Up @@ -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"));
}

Expand All @@ -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<String> columns) {
Expand Down Expand Up @@ -413,7 +418,7 @@ private void makeMigrationTableIndexInstructions(YdbSchemaOperations.Table from,
.collect(toMap(YdbSchemaOperations.Index::getName, Function.identity()));

Function<YdbSchemaOperations.Index, String> 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<YdbSchemaOperations.Index, String> dropIndex = i ->
String.format("ALTER TABLE `%s` DROP INDEX `%s`;", from.getName(), i.getName());
Expand Down Expand Up @@ -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<String> prefixes) {
if (prefixes.isEmpty()) {
return false;
Expand Down
Loading