Skip to content
Merged
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
Expand Up @@ -144,14 +144,7 @@ public static <T extends Data, E> Metamodel<T, E> of(@Nonnull Class<T> rootTable
public static Metamodel<?, ?> canonical(@Nonnull Metamodel<?, ?> metamodel) {
try {
Class<? extends Data> rootTable = metamodel.root();
Class<? extends Data> fieldResolutionClass = rootTable;
if (rootTable.isSealed() && isSealedEntity(rootTable)) {
Class<?>[] permitted = rootTable.getPermittedSubclasses();
if (permitted != null && permitted.length > 0) {
fieldResolutionClass = (Class<? extends Data>) permitted[0];
}
}
String foreignKeyPath = primaryKeyThroughForeignKeyPath(fieldResolutionClass, metamodel.fieldPath());
String foreignKeyPath = primaryKeyThroughForeignKeyPath(fieldResolutionClass(rootTable), metamodel.fieldPath());
if (foreignKeyPath == null) {
return metamodel;
}
Expand Down Expand Up @@ -185,6 +178,69 @@ public static <T extends Data, E> Metamodel<T, E> of(@Nonnull Class<T> rootTable
return List.copyOf(result);
}

/**
* Returns whether the field designated by the given metamodel allows NULL values, following the semantics of
* {@link Metamodel.Key#isNullable()}. A metamodel that carries the {@link Metamodel.Key} marker answers for
* itself. For any other metamodel the record field at the metamodel's path decides: a unique field applies its
* {@code nullsDistinct} setting, an inline record derives its nullability from its constituent fields, and a
* plain field is as nullable as the field itself, because no unique constraint restricts its NULL values.
*
* <p>This backs {@link Metamodel#key(Metamodel)} delegates, which wrap metamodels that do not carry the key
* marker themselves.</p>
*/
public static boolean isNullable(@Nonnull Metamodel<?, ?> metamodel) {
if (metamodel instanceof Metamodel.Key<?, ?> key) {
return key.isNullable();
}
String path = metamodel.fieldPath();
if (path.isEmpty()) {
// The root metamodel designates the row itself rather than a column.
return false;
}
try {
RecordField field = getRecordField(fieldResolutionClass(metamodel.root()), path);
boolean nullsDistinct = getNullsDistinct(field);
boolean inline = !Ref.class.isAssignableFrom(field.type())
&& getORMConverter(field).isEmpty()
&& isRecord(field.type())
&& !Data.class.isAssignableFrom(field.type());
if (inline) {
// Mirrors SimpleKeyMetamodel: an inline record is nullable when its unique constraint treats NULLs
// as distinct and any of its constituent fields is nullable.
boolean fieldIsUnique = field.isAnnotationPresent(UK.class) || field.isAnnotationPresent(PK.class);
if (!fieldIsUnique || !nullsDistinct) {
return false;
}
for (var leaf : metamodel.flatten()) {
if (leaf instanceof Metamodel.Key<?, ?> leafKey && leafKey.isNullable()) {
return true;
}
}
return false;
}
return field.nullable() && nullsDistinct;
} catch (SqlTemplateException e) {
throw new PersistenceException("Failed to resolve nullability for metamodel field at path '%s' on type %s.".formatted(path, metamodel.root().getName()), e);
}
}

/**
* Returns the class that field resolution should inspect for the given root table. For sealed entity interfaces,
* resolution is delegated to the first permitted subclass: the sealed interface itself declares accessor methods
* but is not a record, so {@code getRecordField()} cannot inspect it directly. This mirrors the delegation
* pattern used by {@code findPkField()}.
*/
@SuppressWarnings("unchecked")
private static Class<? extends Data> fieldResolutionClass(@Nonnull Class<? extends Data> rootTable) {
if (rootTable.isSealed() && isSealedEntity(rootTable)) {
Class<?>[] permitted = rootTable.getPermittedSubclasses();
if (permitted != null && permitted.length > 0) {
return (Class<? extends Data>) permitted[0];
}
}
return rootTable;
}

/**
* Returns the path of the foreign key field when {@code path} names the primary key of a table reached through
* a foreign key, or {@code null} otherwise. Mirrors the analysis in {@link #getModel(Class, String)}: inline
Expand Down Expand Up @@ -242,17 +298,7 @@ private static <T extends Data, E> Metamodel<T, E> getModel(@Nonnull Class<T> ro
if (path.isEmpty()) {
return (Metamodel<T, E>) root(rootTable);
}
// For sealed entity interfaces, delegate field resolution to the first permitted subclass.
// The sealed interface itself declares accessor methods but is not a record, so getRecordField()
// cannot inspect it directly. This mirrors the delegation pattern used by findPkField().
Class<? extends Data> fieldResolutionClass = rootTable;
if (rootTable.isSealed() && isSealedEntity(rootTable)) {
Class<?>[] permitted = rootTable.getPermittedSubclasses();
if (permitted != null && permitted.length > 0) {
//noinspection unchecked
fieldResolutionClass = (Class<? extends Data>) permitted[0];
}
}
Class<? extends Data> fieldResolutionClass = fieldResolutionClass(rootTable);
Class<E> fieldType;
String effectivePath;
StringBuilder effectiveField;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2591,6 +2591,27 @@ public void testScrollRejectsNullableCompoundKey() {
.scroll(Scrollable.of(key, 5)));
}

@Test
public void testScrollRejectsWrappedNullableKey() {
// Owner.telephone is @Nullable and not unique; its key() view keeps the field's nullability,
// so scroll rejects it as a cursor key.
var key = Metamodel.key(Owner_.telephone);
assertTrue(key.isNullable());
assertThrows(PersistenceException.class, () ->
ORMTemplate.of(dataSource)
.selectFrom(Owner.class)
.scroll(Scrollable.of(key, 5)));
}

@Test
public void testScrollWithWrappedNonNullableKey() {
// Owner.lastName is @Nonnull; its key() view passes the nullability validation.
var window = ORMTemplate.of(dataSource)
.selectFrom(Owner.class)
.scroll(Scrollable.of(Metamodel.key(Owner_.lastName), 5));
assertEquals(5, window.content().size());
}

@Test
public void testScrollAcceptsNullsNotDistinctCompoundKey() {
// EntityWithNullsNotDistinctUK has @UK(nullsDistinct = false) NullableCompoundUK.
Expand All @@ -2607,10 +2628,11 @@ public void testScrollAcceptsNullsNotDistinctCompoundKey() {
public void testMetamodelKeyFactory() {
// Owner_.id is already a Key (via @PK -> @UK), so key() should return the same instance.
assertSame(Owner_.id, Metamodel.key(Owner_.id));
// Owner_.address is not unique, so key() should wrap it in a KeyDelegate.
// Owner_.address is an inline record, which carries the Key marker; key() returns it as-is.
var addressKey = Metamodel.key(Owner_.address);
assertNotNull(addressKey);
assertInstanceOf(Metamodel.Key.class, addressKey);
assertSame(Owner_.address, addressKey);
// The delegate should be equal to the original metamodel.
assertEquals(Owner_.address, addressKey);
assertEquals(addressKey, Owner_.address);
Expand Down
42 changes: 42 additions & 0 deletions storm-core/src/test/java/st/orm/core/template/MetamodelTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import st.orm.core.model.Owner;
import st.orm.core.model.Owner_;
import st.orm.core.model.Pet;
import st.orm.core.model.Pet_;
import st.orm.core.model.VetSpecialty;
import st.orm.core.model.VetSpecialtyPK;
import st.orm.core.model.VetSpecialty_;
Expand Down Expand Up @@ -276,6 +277,47 @@ public void testNonUniqueInlineRecordIsNotNullable() {
assertFalse(key.isNullable());
}

// Metamodel.key() factory nullability tests.

@Test
public void testWrappedNullableFieldIsNullable() {
// Owner.telephone is @Nullable and not unique, so its generated metamodel carries no Key marker;
// the key() view derives nullability from the underlying field.
Metamodel.Key<Owner, String> key = Metamodel.key(Owner_.telephone);
assertInstanceOf(Metamodel.KeyDelegate.class, key);
assertTrue(key.isNullable());
}

@Test
public void testWrappedNonNullableFieldIsNotNullable() {
// Owner.firstName is @Nonnull, so its key() view is non-nullable.
Metamodel.Key<Owner, String> key = Metamodel.key(Owner_.firstName);
assertInstanceOf(Metamodel.KeyDelegate.class, key);
assertFalse(key.isNullable());
}

@Test
public void testWrappedFactoryMetamodelWithNullableFieldIsNullable() {
// The factory route for manually constructed metamodels keeps the field's nullability.
Metamodel<Owner, String> telephone = Metamodel.of(Owner.class, "telephone");
assertTrue(Metamodel.key(telephone).isNullable());
}

@Test
public void testWrappedNestedFieldResolvesNullabilityAtLeaf() {
// A nested path resolves nullability at the leaf field.
Metamodel<Pet, String> telephone = Metamodel.of(Pet.class, "owner.telephone");
assertTrue(Metamodel.key(telephone).isNullable());
Metamodel<Pet, String> firstName = Metamodel.of(Pet.class, "owner.firstName");
assertFalse(Metamodel.key(firstName).isNullable());
}

@Test
public void testWrappedNullableForeignKeyIsNullable() {
// Pet.owner is a @Nullable @FK, so its foreign key column allows NULLs.
assertTrue(Metamodel.key(Pet_.owner).isNullable());
}

@Test
@SuppressWarnings("unchecked")
public void testNullableCompoundKeyLeafReturnsNull() {
Expand Down
14 changes: 13 additions & 1 deletion storm-foundation/src/main/java/st/orm/Metamodel.java
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ interface Key<T extends Data, E> extends Metamodel<T, E> {
* context where the key is used. Using a non-unique column as a keyset pagination key will silently skip rows when
* duplicate values span page boundaries.</p>
*
* <p>The returned key reports {@link Key#isNullable()} from the underlying field: a nullable field stays nullable
* when viewed as a key, so keyset pagination keeps rejecting it.</p>
*
* @param metamodel the metamodel to view as a key.
* @return a {@code Key} instance backed by the given metamodel.
* @param <T> the root table type.
Expand Down Expand Up @@ -263,7 +266,16 @@ record KeyDelegate<T extends Data, E>(@Nonnull Metamodel<T, E> delegate) impleme
@Override public boolean isIdentical(@Nonnull T a, @Nonnull T b) { return delegate.isIdentical(a, b); }
@Override public boolean isSame(@Nonnull T a, @Nonnull T b) { return delegate.isSame(a, b); }
@Override public List<Metamodel<T, ?>> flatten() { return delegate.flatten(); }
@Override public boolean isNullable() { return delegate instanceof Key<?, ?> key && key.isNullable(); }

/**
* Returns the nullability of the underlying field. A delegate around a {@link Key} answers through that key;
* any other delegate derives the answer from the record field the metamodel designates, so a nullable field
* wrapped by {@link #key(Metamodel)} is still rejected as a keyset pagination cursor.
*/
@Override
public boolean isNullable() {
return delegate instanceof Key<?, ?> key ? key.isNullable() : MetamodelHelper.isNullable(delegate);
}

@Override
public boolean equals(Object o) {
Expand Down
26 changes: 22 additions & 4 deletions storm-foundation/src/main/java/st/orm/MetamodelHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ class MetamodelHelper {
private static final Method ROOT_METHOD;
private static final Method OF_METHOD;
private static final Method FLATTEN_METHOD;
private static final Method IS_NULLABLE_METHOD;

static {
try {
Class<?> factoryClass = Class.forName("st.orm.core.template.impl.MetamodelFactory");
ROOT_METHOD = factoryClass.getMethod("root", Class.class);
OF_METHOD = factoryClass.getMethod("of", Class.class, String.class);
FLATTEN_METHOD = factoryClass.getMethod("flatten", Navigable.class);
IS_NULLABLE_METHOD = factoryClass.getMethod("isNullable", Metamodel.class);
} catch (ReflectiveOperationException e) {
var ex = new ExceptionInInitializerError("Failed to initialize Metamodel. Please ensure that storm-core is present in the classpath.");
ex.initCause(e);
Expand All @@ -35,8 +37,8 @@ static <T extends Data> Metamodel<T, T> root(@Nonnull Class<T> rootTable) {
return (Metamodel<T, T>) ROOT_METHOD.invoke(null, rootTable);
} catch (InvocationTargetException e) {
throw e.getTargetException();
} catch (ReflectiveOperationException e) {
throw new RuntimeException("Reflection invocation failed for MetamodelFactory.of", e);
} catch (IllegalAccessException e) {
throw new RuntimeException("Reflection invocation failed for MetamodelFactory.root", e);
}
} catch (RuntimeException e) {
throw e;
Expand All @@ -52,7 +54,7 @@ static <T extends Data, E> Metamodel<T, E> of(Class<T> rootTable, String path) {
return (Metamodel<T, E>) OF_METHOD.invoke(null, rootTable, path);
} catch (InvocationTargetException e) {
throw e.getTargetException();
} catch (ReflectiveOperationException e) {
} catch (IllegalAccessException e) {
throw new RuntimeException("Reflection invocation failed for MetamodelFactory.of", e);
}
} catch (RuntimeException e) {
Expand All @@ -69,7 +71,7 @@ static <T extends Data, E> Metamodel<T, E> of(Class<T> rootTable, String path) {
return (List<Metamodel<T, ?>>) FLATTEN_METHOD.invoke(null, metamodel);
} catch (InvocationTargetException e) {
throw e.getTargetException();
} catch (ReflectiveOperationException e) {
} catch (IllegalAccessException e) {
throw new RuntimeException("Reflection invocation failed for MetamodelFactory.flatten", e);
}
} catch (RuntimeException e) {
Expand All @@ -78,4 +80,20 @@ static <T extends Data, E> Metamodel<T, E> of(Class<T> rootTable, String path) {
throw new PersistenceException(t);
}
}

static boolean isNullable(@Nonnull Metamodel<?, ?> metamodel) {
try {
try {
return (Boolean) IS_NULLABLE_METHOD.invoke(null, metamodel);
} catch (InvocationTargetException e) {
throw e.getTargetException();
} catch (IllegalAccessException e) {
throw new RuntimeException("Reflection invocation failed for MetamodelFactory.isNullable", e);
}
} catch (RuntimeException e) {
throw e;
} catch (Throwable t) {
throw new PersistenceException(t);
}
}
}
8 changes: 4 additions & 4 deletions storm-foundation/src/test/java/st/orm/KeyDelegateTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ void keyDelegateDelegatesAllMethods() {
}

@Test
void keyDelegateIsNotNullableForNonKeyMetamodel() {
TestMetamodel metamodel = new TestMetamodel();
Metamodel.Key<TestData, Integer> key = Metamodel.key(metamodel);
assertFalse(key.isNullable());
void keyDelegateIsNotNullableWhenDelegateIsNonNullableKey() {
TestKeyMetamodel nonNullableKey = new TestKeyMetamodel(false);
Metamodel.KeyDelegate<TestData, Integer> delegate = new Metamodel.KeyDelegate<>(nonNullableKey);
assertFalse(delegate.isNullable());
}

@Test
Expand Down
Loading