diff --git a/docs/relationships.md b/docs/relationships.md
index 2692be329..50ae67df8 100644
--- a/docs/relationships.md
+++ b/docs/relationships.md
@@ -463,6 +463,34 @@ Storm extracts the primary key from the `User` entity and uses it as the value f
+### Key Chains
+
+The referenced entity's primary key may itself be a foreign key — or a compound key record. Storm follows this *key chain* to its terminal columns. A dependent one-to-one on an entity that is itself a dependent one-to-one works the same way as the single-level case:
+
+
+
+
+```kotlin
+data class ProfileAudit(
+ @PK(generation = NONE) @FK val profile: UserProfile, // UserProfile's own PK is the FK to User
+ val remark: String
+) : Entity
+```
+
+
+
+
+```java
+record ProfileAudit(@PK(generation = NONE) @FK UserProfile profile, // UserProfile's own PK is the FK to User
+ String remark
+) implements Entity {}
+```
+
+
+
+
+The foreign key spans the same columns as the terminal key of the chain: a single-column chain resolves to one column named by the FK convention (`profile_id` here), and a compound key contributes the referenced key's column names. Circular key chains are rejected at model construction with a clear error.
+
---
## Relationship Loading Behavior
diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/MergeEntityRepositoryImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/MergeEntityRepositoryImpl.java
index cbb552a68..fa77bc4bc 100644
--- a/storm-core/src/main/java/st/orm/core/repository/impl/MergeEntityRepositoryImpl.java
+++ b/storm-core/src/main/java/st/orm/core/repository/impl/MergeEntityRepositoryImpl.java
@@ -62,7 +62,7 @@
* FROM clause (Oracle), {@link #statementSuffix()} for dialects that require a statement terminator (SQL Server),
* the version expressions, and {@link #mergeInsert()} for the dialect-specific WHEN NOT MATCHED clause.
*
- * @since 1.12
+ * @since 1.11
*/
public abstract class MergeEntityRepositoryImpl, ID> extends EntityRepositoryImpl {
diff --git a/storm-core/src/main/java/st/orm/core/spi/JsonString.java b/storm-core/src/main/java/st/orm/core/spi/JsonString.java
index 79ab1761f..63eaa8d61 100644
--- a/storm-core/src/main/java/st/orm/core/spi/JsonString.java
+++ b/storm-core/src/main/java/st/orm/core/spi/JsonString.java
@@ -26,7 +26,7 @@
* {@code jsonb} columns.
*
* @param value the serialized JSON text.
- * @since 1.12
+ * @since 1.11
*/
public record JsonString(@Nonnull String value) {
diff --git a/storm-core/src/main/java/st/orm/core/template/Column.java b/storm-core/src/main/java/st/orm/core/template/Column.java
index 7499287f8..4a4b83e47 100644
--- a/storm-core/src/main/java/st/orm/core/template/Column.java
+++ b/storm-core/src/main/java/st/orm/core/template/Column.java
@@ -49,10 +49,26 @@ public interface Column {
/**
* Gets the type of the column.
*
+ * This is the declared field type: for foreign key columns the referenced entity type, for
+ * converter-backed columns the converted field type's parameter type, and the field type otherwise.
+ *
* @return the type of the column.
*/
Class> type();
+ /**
+ * Gets the Java type of the value as it is persisted to this column.
+ *
+ * Unlike {@link #type()}, this is the terminal type after following key chains: for a foreign key
+ * column it is the type of the referenced key's terminal field (following foreign keys that are
+ * themselves primary keys, and record key components), letting callers reason about the physical column
+ * without knowledge of key structure. For all other columns it equals {@link #type()}.
+ *
+ * @return the Java type persisted to this column.
+ * @since 1.11
+ */
+ Class> persistedType();
+
/**
* Determines if the column is a primary key.
*
diff --git a/storm-core/src/main/java/st/orm/core/template/SqlDialect.java b/storm-core/src/main/java/st/orm/core/template/SqlDialect.java
index ca21af023..002026896 100644
--- a/storm-core/src/main/java/st/orm/core/template/SqlDialect.java
+++ b/storm-core/src/main/java/st/orm/core/template/SqlDialect.java
@@ -403,7 +403,7 @@ default void setParameter(@Nonnull PreparedStatement preparedStatement, int inde
* @param index the parameter index.
* @param json the serialized JSON value.
* @throws SQLException if a database access error occurs.
- * @since 1.12
+ * @since 1.11
*/
default void setParameter(@Nonnull PreparedStatement preparedStatement, int index,
@Nonnull JsonString json) throws SQLException {
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ColumnImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/ColumnImpl.java
index 69fde360f..354d4ccb7 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ColumnImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ColumnImpl.java
@@ -47,6 +47,7 @@ public record ColumnImpl(
@Nonnull Name columnName,
int index,
@Nonnull Class> type,
+ @Nonnull Class> persistedType,
boolean primaryKey,
@Nonnull GenerationStrategy generation,
@Nonnull String sequence,
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
index 111516bf7..80c823915 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
@@ -24,6 +24,7 @@
import static st.orm.core.template.impl.RecordReflection.getColumnName;
import static st.orm.core.template.impl.RecordReflection.getDiscriminatorColumn;
import static st.orm.core.template.impl.RecordReflection.getDiscriminatorColumnJavaType;
+import static st.orm.core.template.impl.RecordReflection.getFkLeaves;
import static st.orm.core.template.impl.RecordReflection.getForeignKeys;
import static st.orm.core.template.impl.RecordReflection.getGenerationStrategy;
import static st.orm.core.template.impl.RecordReflection.getRecordType;
@@ -130,7 +131,7 @@ private static List adjustColumnsForJoinedSubtype(@Nonnull List
if (ci.primaryKey()) {
// PK: override generation to NONE (PK is provided from base INSERT).
adjusted.add(new ColumnImpl(
- ci.columnName(), ci.index(), ci.type(), true,
+ ci.columnName(), ci.index(), ci.type(), ci.persistedType(), true,
GenerationStrategy.NONE, "",
ci.foreignKey(), ci.foreignKeyGeneration(), ci.keyIndex(), ci.nullable(),
ci.insertable(), ci.updatable(), ci.version(), ci.ref(),
@@ -147,7 +148,7 @@ private static List adjustColumnsForJoinedSubtype(@Nonnull List
if (isBaseField) {
// Base non-PK field: not insertable or updatable in extension table.
adjusted.add(new ColumnImpl(
- ci.columnName(), ci.index(), ci.type(), false,
+ ci.columnName(), ci.index(), ci.type(), ci.persistedType(), false,
ci.generation(), ci.sequence(),
ci.foreignKey(), ci.foreignKeyGeneration(), ci.keyIndex(), ci.nullable(),
false, false, ci.version(), ci.ref(),
@@ -204,6 +205,7 @@ private static Model createSealedModel(@Nonnull Mode
new ColumnName(discriminatorColumnName, false),
index.getAndIncrement(),
getDiscriminatorColumnJavaType(sealedType),
+ getDiscriminatorColumnJavaType(sealedType),
false, // not primary key
GenerationStrategy.NONE,
"",
@@ -295,6 +297,7 @@ private static Model createSealedModel(@Nonnull Mode
columnName,
index.getAndIncrement(),
field.type(),
+ field.type(),
isPk,
isPk ? getGenerationStrategy(field) : GenerationStrategy.NONE,
isPk ? getSequence(field) : "",
@@ -398,7 +401,7 @@ private static void createColumns(@Nonnull BuildContext ctx,
throw new SqlTemplateException("Column count does not match value count.");
}
ColumnSpec spec = buildSpec(field, effectivePrimaryKey, fkAnnotation, parentNullable, inheritedPersist, pkContext);
- emitColumns(ctx, field, columnMetamodel, null, spec, keyScope, columnNames, columnTypes);
+ emitColumns(ctx, field, columnMetamodel, null, spec, keyScope, columnNames, columnTypes, columnTypes);
return;
}
if (isRecord(field.type()) && !fkAnnotation) {
@@ -434,14 +437,18 @@ private static void createColumns(@Nonnull BuildContext ctx,
(Class extends Data>) ownMetamodel.root(),
ownMetamodel.fieldPath().isEmpty() ? field.name() : ownMetamodel.fieldPath() + "." + pkField.name());
}
- emitColumns(ctx, field, ownMetamodel, secondaryMetamodel, spec, keyScope, fkNames, nCopies(fkNames.size(), spec.dataType()));
+ var leaves = getFkLeaves(field);
+ List> persistedTypes = leaves != null && leaves.size() == fkNames.size()
+ ? leaves.stream().>map(leaf -> leaf.field().type()).toList()
+ : nCopies(fkNames.size(), spec.dataType());
+ emitColumns(ctx, field, ownMetamodel, secondaryMetamodel, spec, keyScope, fkNames, nCopies(fkNames.size(), spec.dataType()), persistedTypes);
if (!spec.ref()) {
expandForeignRelation(ctx, ownMetamodel, field, spec.nullable());
}
return;
}
ColumnName columnName = getColumnName(field, ctx.builder().columnNameResolver());
- emitColumns(ctx, field, columnMetamodel, null, spec, keyScope, List.of(columnName), List.of(spec.dataType()));
+ emitColumns(ctx, field, columnMetamodel, null, spec, keyScope, List.of(columnName), List.of(spec.dataType()), List.of(spec.dataType()));
} catch (SqlTemplateException e) {
throw new UncheckedSqlTemplateException(e);
}
@@ -499,8 +506,9 @@ private static void emitColumns(@Nonnull BuildContext ctx,
@Nonnull ColumnSpec spec,
@Nonnull KeyScope keyScope,
@Nonnull List extends Name> names,
- @Nonnull List> types) throws SqlTemplateException {
- if (names.size() != types.size()) {
+ @Nonnull List> types,
+ @Nonnull List> persistedTypes) throws SqlTemplateException {
+ if (names.size() != types.size() || names.size() != persistedTypes.size()) {
throw new SqlTemplateException("Column count does not match type count.");
}
for (int i = 0; i < names.size(); i++) {
@@ -509,6 +517,7 @@ private static void emitColumns(@Nonnull BuildContext ctx,
names.get(i),
ctx.index().getAndIncrement(),
types.get(i),
+ persistedTypes.get(i),
spec.primaryKey(),
spec.generation(),
spec.sequence(),
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
index dda62075e..6409488c9 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
@@ -25,6 +25,7 @@
import static st.orm.core.spi.Providers.getORMConverter;
import static st.orm.core.template.impl.ObjectMapperFactory.nullableHint;
import static st.orm.core.template.impl.RecordReflection.getDiscriminatorValue;
+import static st.orm.core.template.impl.RecordReflection.getFkLeaves;
import static st.orm.core.template.impl.RecordReflection.getRefDataType;
import static st.orm.core.template.impl.RecordReflection.isJoinedEntity;
import static st.orm.core.template.impl.RecordReflection.isPolymorphicData;
@@ -131,6 +132,14 @@ public final class ModelImpl implements Model {
*/
private final List converters;
+ /**
+ * Per-column accessor paths for foreign key columns, following the referenced key chain from the foreign
+ * key field's value down to the terminal field bound to the column. Precomputed at model construction so
+ * value extraction is a plain loop of accessor invocations; {@code null} for columns that do not use chain
+ * extraction (non-foreign-key columns and polymorphic foreign keys).
+ */
+ private final RecordField[][] keyPaths;
+
/**
* Parent metamodel for each converter group, keyed by the group's 0-based start column index. A converter reads
* its value by reflectively invoking the field accessor on the record passed to
@@ -177,6 +186,7 @@ public ModelImpl(@Nonnull RecordType recordType,
this.polymorphicFkDiscriminatorColumns = initPolymorphicFkDiscriminatorColumns(this.fields, this.columns);
this.converters = initConverters(this.fields, this.columns);
this.converterParents = initConverterParents(this.converters, this.columns);
+ this.keyPaths = initKeyPaths(this.fields, this.columns);
this.primaryKeyField = initPrimaryKeyField(this.recordType);
this.declaredColumns = initDeclaredColumns(this.columns);
this.primaryKeyMetamodel = initPrimaryKeyMetamodel(this.declaredColumns);
@@ -236,6 +246,63 @@ private static Map> initPolymorphicFkDiscriminatorColumns(
return map.isEmpty() ? Map.of() : Map.copyOf(map);
}
+ private static RecordField[][] initKeyPaths(@Nonnull List fields,
+ @Nonnull List columns) throws SqlTemplateException {
+ var paths = new RecordField[columns.size()][];
+ Map columnsByField = new HashMap<>();
+ for (int i = 0; i < columns.size(); i++) {
+ if (columns.get(i).foreignKey()) {
+ columnsByField.merge(fields.get(i), 1, Integer::sum);
+ }
+ }
+ Map>> leavesByField = new HashMap<>();
+ for (int i = 0; i < columns.size(); i++) {
+ var column = columns.get(i);
+ if (!column.foreignKey()) {
+ continue;
+ }
+ var field = fields.get(i);
+ var cached = leavesByField.get(field);
+ if (cached == null) {
+ cached = Optional.ofNullable(getFkLeaves(field));
+ leavesByField.put(field, cached);
+ }
+ // Only use chain extraction when the flattened key matches the emitted columns one-to-one;
+ // polymorphic foreign keys keep the legacy extraction.
+ var leaves = cached.orElse(null);
+ if (leaves == null || leaves.size() != columnsByField.get(field)) {
+ continue;
+ }
+ int keyIndex = column.keyIndex();
+ if (keyIndex >= 1 && keyIndex <= leaves.size()) {
+ paths[i] = leaves.get(keyIndex - 1).path().toArray(new RecordField[0]);
+ }
+ }
+ return paths;
+ }
+
+ /**
+ * Extracts the value bound to a foreign key column by walking the column's key chain path. {@link Ref}
+ * values substitute their identifier for the next step of the path.
+ */
+ @Nullable
+ private Object extractKeyValue(@Nullable Object value,
+ @Nonnull RecordField[] path,
+ @Nonnull Column column) throws SqlTemplateException {
+ if (value == null) {
+ return null;
+ }
+ if (!(value instanceof Ref) && !(value instanceof Data)) {
+ throw new SqlTemplateException("Invalid foreign key type for column '%s'. Expected a Ref or Data type, but got an unrecognized value type. Ensure the foreign key field is correctly typed.".formatted(column.name()));
+ }
+ for (int i = 0; i < path.length && value != null; i++) {
+ value = value instanceof Ref> ref
+ ? ref.id() // The identifier is the result of the next accessor.
+ : REFLECTION.invoke(path[i], value);
+ }
+ return value;
+ }
+
private static List initConverters(List fields, List columns) {
var converters = new ORMConverter[columns.size()];
for (int i = 0; i < columns.size(); i++) {
@@ -487,6 +554,10 @@ public void forEachValue(@Nonnull Metamodel metamodel,
Object value;
if (object instanceof Data data) {
value = REFLECTION.getId(data);
+ // Follow the key chain: the identifier of an entity-keyed entity is itself an entity.
+ while (value instanceof Data nested) {
+ value = REFLECTION.getId(nested);
+ }
} else {
value = object;
}
@@ -549,12 +620,21 @@ private void forEachInlineValue(@Nonnull Metamodel metamodel,
.formatted(componentName, object.getClass().getSimpleName()));
}
Object value = REFLECTION.getRecordValue(object, componentIndex);
- // Handle FK components: extract the primary key ID from the Data object.
- if (column.foreignKey() && value instanceof Data data) {
- value = REFLECTION.getId(data);
- }
- // Handle compound keys (e.g., compound FK or compound PK within the inline record).
- if (value != null && (column.primaryKey() || column.foreignKey()) && isRecord(value.getClass())) {
+ if (column.foreignKey()) {
+ // Handle FK components: walk the key chain from the referenced Data object to the column value.
+ var path = keyPaths[column.index() - 1];
+ if (path != null) {
+ value = extractKeyValue(value, path, column);
+ } else {
+ if (value instanceof Data data) {
+ value = REFLECTION.getId(data);
+ }
+ if (value != null && isRecord(value.getClass())) {
+ value = REFLECTION.getRecordValue(value, column.keyIndex() - 1);
+ }
+ }
+ } else if (value != null && column.primaryKey() && isRecord(value.getClass())) {
+ // Handle compound primary keys within the inline record.
value = REFLECTION.getRecordValue(value, column.keyIndex() - 1);
}
consumer.accept(column, map(column, value));
@@ -627,20 +707,28 @@ private void forEachValueOrdered(@Nonnull List view,
throw new SqlTemplateException("Cannot write NULL to non-nullable column '%s'. Ensure the entity field has a value before inserting or updating, or %s if NULL is intended.".formatted(column.name(), nullableHint(type())));
}
if (column.foreignKey()) {
- if (value instanceof Ref> ref) {
- Class> polymorphicSealedType = polymorphicFkDiscriminatorColumns.get(column.index());
+ if (value instanceof Ref> ref && polymorphicFkDiscriminatorColumns.containsKey(column.index())) {
// Polymorphic FK discriminator column: extract the discriminator value from the Ref's
// concrete type instead of the FK id.
- value = polymorphicSealedType != null
- ? getDiscriminatorValue(ref.type(), polymorphicSealedType)
- : ref.id();
- } else if (value instanceof Data data) {
- value = REFLECTION.getId(data);
- } else if (value != null) {
- throw new SqlTemplateException("Invalid foreign key type for column '%s'. Expected a Ref or Data type, but got an unrecognized value type. Ensure the foreign key field is correctly typed.".formatted(column.name()));
+ value = getDiscriminatorValue(ref.type(), polymorphicFkDiscriminatorColumns.get(column.index()));
+ } else {
+ var path = keyPaths[index - 1];
+ if (path != null) {
+ value = extractKeyValue(value, path, column);
+ } else {
+ if (value instanceof Ref> ref) {
+ value = ref.id();
+ } else if (value instanceof Data data) {
+ value = REFLECTION.getId(data);
+ } else if (value != null) {
+ throw new SqlTemplateException("Invalid foreign key type for column '%s'. Expected a Ref or Data type, but got an unrecognized value type. Ensure the foreign key field is correctly typed.".formatted(column.name()));
+ }
+ if (value != null && isRecord(value.getClass())) {
+ value = REFLECTION.getRecordValue(value, column.keyIndex() - 1);
+ }
+ }
}
- }
- if (value != null && (column.primaryKey() || column.foreignKey()) && isRecord(value.getClass())) {
+ } else if (value != null && column.primaryKey() && isRecord(value.getClass())) {
value = REFLECTION.getRecordValue(value, column.keyIndex() - 1);
}
consumer.accept(column, map(column, value));
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java
index 68f0e91b5..0df9c5633 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java
@@ -1156,7 +1156,13 @@ private static PkInfo calculatePkInfo(@Nonnull RecordType type) throws SqlTempla
// For composite PKs (record types), we need the constructor.
Constructor> pkConstructor = null;
if (isRecord(pkField.type()) && pkColumnCount > 1) {
- pkConstructor = getRecordType(pkField.type()).constructor();
+ var pkRecordType = getRecordType(pkField.type());
+ // The shortcut only applies to flat key records. When the key spans nested records or entities
+ // (key chains), its flat column count exceeds the constructor arity and the key must be built
+ // through the regular argument plan; the early cache lookup is skipped in that case.
+ if (pkRecordType.constructor().getParameterCount() == pkColumnCount) {
+ pkConstructor = pkRecordType.constructor();
+ }
}
return new PkInfo(offset, pkColumnCount, pkConstructor);
}
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
index 1952188a4..d0083be17 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
@@ -180,19 +180,102 @@ public static Optional findPkField(@Nonnull Class> table) {
*
* @param table the table to obtain the primary key components for.
*/
- static Stream getNestedPkFields(@Nonnull Class> table) {
- var pkField = findPkField(table).orElse(null);
- if (pkField == null) {
- return Stream.of();
+ /**
+ * A terminal field of a primary key, together with the accessor path that navigates to it from a value of
+ * the key's declaring table.
+ *
+ * The path follows the key chain: a primary key that is a foreign key to another entity steps through
+ * that entity's primary key, and so on; plain record keys contribute their components. The path therefore
+ * ends at a field that is bound to a single database column.
+ *
+ * @param path the accessor path from the declaring table's key to the terminal field.
+ */
+ record KeyLeaf(@Nonnull List path) {
+ RecordField field() {
+ return path.getLast();
+ }
+ }
+
+ /**
+ * Flattens the primary key of the given table into its terminal fields, in declaration order — one leaf
+ * per database column of the key.
+ *
+ * @param table the table whose primary key to flatten.
+ * @return the terminal fields of the key with their accessor paths.
+ * @throws SqlTemplateException if the key chain is circular or a referenced entity lacks a primary key.
+ */
+ static List getPkLeaves(@Nonnull Class> table) throws SqlTemplateException {
+ var leaves = new ArrayList();
+ flattenPk(table, new ArrayList<>(), new HashSet<>(), leaves);
+ return leaves;
+ }
+
+ /**
+ * Returns the flattened key leaves for the target of the given foreign key field, or {@code null} when the
+ * target's key cannot be flattened — polymorphic foreign keys bind a discriminator and an identifier
+ * instead of the target's key columns.
+ */
+ @Nullable
+ static List getFkLeaves(@Nonnull RecordField field) throws SqlTemplateException {
+ Class> target = getFkTargetType(field);
+ if (isPolymorphicData(target)) {
+ return null;
+ }
+ return getPkLeaves(target);
+ }
+
+ /**
+ * Resolves the entity type a foreign key field refers to, unwrapping {@link Ref} fields and selecting the
+ * first permitted subclass for sealed entity hierarchies.
+ */
+ static Class> getFkTargetType(@Nonnull RecordField field) throws SqlTemplateException {
+ Class> fkType = Ref.class.isAssignableFrom(field.type())
+ ? getRefDataType(field)
+ : field.type();
+ if (fkType.isSealed() && isSealedEntity(fkType)) {
+ Class>[] permitted = fkType.getPermittedSubclasses();
+ if (permitted != null && permitted.length > 0) {
+ fkType = permitted[0];
+ }
+ }
+ return fkType;
+ }
+
+ private static void flattenPk(@Nonnull Class> table,
+ @Nonnull List path,
+ @Nonnull Set> visited,
+ @Nonnull List leaves) throws SqlTemplateException {
+ if (!visited.add(table)) {
+ throw new SqlTemplateException(
+ "Circular key chain detected at %s. A primary key must not reference itself through its foreign keys."
+ .formatted(table.getSimpleName()));
+ }
+ var pkField = findPkField(table).orElseThrow(() ->
+ new SqlTemplateException("No primary key found for type: %s.".formatted(table.getSimpleName())));
+ path.add(pkField);
+ flattenKeyField(pkField, path, visited, leaves);
+ path.removeLast();
+ visited.remove(table);
+ }
+
+ private static void flattenKeyField(@Nonnull RecordField field,
+ @Nonnull List path,
+ @Nonnull Set> visited,
+ @Nonnull List leaves) throws SqlTemplateException {
+ if (field.isAnnotationPresent(FK.class)) {
+ flattenPk(getFkTargetType(field), path, visited, leaves);
+ return;
}
- if (pkField.isAnnotationPresent(FK.class)) {
- // If the primary key component is also a foreign key, return the component itself.
- return Stream.of(pkField);
+ var recordType = REFLECTION.findRecordType(field.type()).orElse(null);
+ if (recordType == null) {
+ leaves.add(new KeyLeaf(List.copyOf(path)));
+ return;
}
- if (!REFLECTION.findRecordType(pkField.type()).isPresent()) {
- return Stream.of(pkField);
+ for (var component : recordType.fields()) {
+ path.add(component);
+ flattenKeyField(component, path, visited, leaves);
+ path.removeLast();
}
- return RecordReflection.getRecordFields(pkField.type()).stream();
}
@SuppressWarnings("unchecked")
@@ -615,14 +698,15 @@ static List getPrimaryKeys(@Nonnull RecordField field,
DbColumn[] dbColumns = field.getAnnotations(DbColumn.class);
RecordType fieldType = REFLECTION.findRecordType(field.type()).orElse(null);
if (fieldType != null) {
- columnNames = new ArrayList<>();
- var pkFields = fieldType.fields();
- for (int i = 0; i < pkFields.size(); i++) {
- var pkField = pkFields.get(i);
+ var leaves = new ArrayList();
+ flattenKeyField(field, new ArrayList<>(), new HashSet<>(), leaves);
+ columnNames = new ArrayList<>(leaves.size());
+ for (int i = 0; i < leaves.size(); i++) {
+ var leafField = leaves.get(i).field();
DbColumn nestedDbColumn = i < dbColumns.length
? dbColumns[i]
- : pkField.getAnnotation(DbColumn.class); // Top level is prioritized over nested.
- String name = columnNameResolver.resolveColumnName(pkField);
+ : leafField.getAnnotation(DbColumn.class); // Top level is prioritized over nested.
+ String name = columnNameResolver.resolveColumnName(leafField);
columnNames.add(new ColumnName(name, nestedDbColumn != null && nestedDbColumn.escape()));
}
} else {
@@ -672,22 +756,22 @@ static List getForeignKeys(@Nonnull RecordField field,
}
}
DbColumn[] dbColumns = field.getAnnotations(DbColumn.class);
- List pkFields = getNestedPkFields(fkType).toList();
- if (pkFields.size() == 1) {
- // If there is only one PK component, use the column name of the FK component.
+ List leaves = getPkLeaves(fkType);
+ if (leaves.size() == 1) {
+ // If the key resolves to a single column, use the column name of the FK component.
DbColumn dbColumn = dbColumns.length > 0
? dbColumns[0]
- : pkFields.getFirst().getAnnotation(DbColumn.class);
+ : leaves.getFirst().field().getAnnotation(DbColumn.class);
String name = foreignKeyResolver.resolveColumnName(field, REFLECTION.getRecordType(fkType));
return List.of(new ColumnName(name, dbColumn != null && dbColumn.escape()));
}
- columnNames = new ArrayList<>(pkFields.size());
- for (int i = 0; i < pkFields.size(); i++) {
- var pkComponent = pkFields.get(i);
+ columnNames = new ArrayList<>(leaves.size());
+ for (int i = 0; i < leaves.size(); i++) {
+ var leafField = leaves.get(i).field();
DbColumn nestedDbColumn = i < dbColumns.length
? dbColumns[i]
- : pkComponent.getAnnotation(DbColumn.class); // Top-level prioritized.
- String name = columnNameResolver.resolveColumnName(pkComponent);
+ : leafField.getAnnotation(DbColumn.class); // Top-level prioritized.
+ String name = columnNameResolver.resolveColumnName(leafField);
columnNames.add(new ColumnName(name, nestedDbColumn != null && nestedDbColumn.escape()));
}
return columnNames;
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
index 497768d99..f791c05ed 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
@@ -431,17 +431,18 @@ private void validateType(
continue;
}
DbColumn dbCol = dbColumn.get();
- // Type compatibility check.
- Compatibility compatibility = typeCompatibility.check(column.type(), dbCol.dataType(), dbCol.typeName());
+ // Type compatibility check against the persisted type, so foreign key columns are validated
+ // against the referenced key's terminal type rather than being skipped as unknown entity types.
+ Compatibility compatibility = typeCompatibility.check(column.persistedType(), dbCol.dataType(), dbCol.typeName());
if (compatibility == Compatibility.NARROWING) {
errors.add(new SchemaValidationError(type, ErrorKind.TYPE_NARROWING,
"Column '%s' in table '%s': Java type '%s' mapped to SQL type '%s' (%d) may involve precision or range loss."
- .formatted(columnName, qualifiedTableName, column.type().getSimpleName(),
+ .formatted(columnName, qualifiedTableName, column.persistedType().getSimpleName(),
dbCol.typeName(), dbCol.dataType())));
} else if (compatibility == Compatibility.INCOMPATIBLE) {
errors.add(new SchemaValidationError(type, ErrorKind.TYPE_INCOMPATIBLE,
"Column '%s' in table '%s': Java type '%s' is not compatible with SQL type '%s' (%d)."
- .formatted(columnName, qualifiedTableName, column.type().getSimpleName(),
+ .formatted(columnName, qualifiedTableName, column.persistedType().getSimpleName(),
dbCol.typeName(), dbCol.dataType())));
}
// Nullability check: entity field is non-nullable but database column allows NULL.
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/RecordReflectionTest.java b/storm-core/src/test/java/st/orm/core/template/impl/RecordReflectionTest.java
index 20b52208f..e94f8e04b 100644
--- a/storm-core/src/test/java/st/orm/core/template/impl/RecordReflectionTest.java
+++ b/storm-core/src/test/java/st/orm/core/template/impl/RecordReflectionTest.java
@@ -232,29 +232,32 @@ void testFindPkFieldCompound() {
assertEquals(CompoundPk.class, pkField.get().type());
}
- // getNestedPkFields tests
+ // getPkLeaves tests
@Test
- void testGetNestedPkFieldsSimple() {
- List pkFields = RecordReflection.getNestedPkFields(SimpleEntity.class).toList();
- assertEquals(1, pkFields.size());
- assertEquals("id", pkFields.get(0).name());
+ void testGetPkLeavesSimple() throws SqlTemplateException {
+ var leaves = RecordReflection.getPkLeaves(SimpleEntity.class);
+ assertEquals(1, leaves.size());
+ assertEquals("id", leaves.get(0).field().name());
}
@Test
- void testGetNestedPkFieldsCompound() {
- List pkFields = RecordReflection.getNestedPkFields(EntityWithCompoundPk.class).toList();
- assertEquals(2, pkFields.size());
- assertEquals("partA", pkFields.get(0).name());
- assertEquals("partB", pkFields.get(1).name());
+ void testGetPkLeavesCompound() throws SqlTemplateException {
+ var leaves = RecordReflection.getPkLeaves(EntityWithCompoundPk.class);
+ assertEquals(2, leaves.size());
+ assertEquals("partA", leaves.get(0).field().name());
+ assertEquals("partB", leaves.get(1).field().name());
}
@Test
- void testGetNestedPkFieldsFkPk() {
- // When PK is also FK, the field itself is returned (not the nested PK of the referenced entity).
- List pkFields = RecordReflection.getNestedPkFields(EntityWithFkPk.class).toList();
- assertEquals(1, pkFields.size());
- assertEquals("ref", pkFields.get(0).name());
+ void testGetPkLeavesFkPk() throws SqlTemplateException {
+ // When PK is also FK, the key chain is followed into the referenced entity's primary key.
+ var leaves = RecordReflection.getPkLeaves(EntityWithFkPk.class);
+ assertEquals(1, leaves.size());
+ assertEquals("id", leaves.get(0).field().name());
+ // The accessor path steps through the FK field into the referenced key.
+ assertEquals(2, leaves.get(0).path().size());
+ assertEquals("ref", leaves.get(0).path().get(0).name());
}
// getFkFields tests
@@ -573,13 +576,13 @@ void testGetRefDataTypeSealedData() throws SqlTemplateException {
assertEquals(SealedData.class, refDataType);
}
- // Sealed entity: getNestedPkFields on sealed type delegates to first permitted subclass
+ // Sealed entity: getPkLeaves on sealed type delegates to first permitted subclass
@Test
- void testGetNestedPkFieldsSealedEntity() {
- List pkFields = RecordReflection.getNestedPkFields(SealedAnimal.class).toList();
- assertEquals(1, pkFields.size());
- assertEquals("id", pkFields.get(0).name());
+ void testGetPkLeavesSealedEntity() throws SqlTemplateException {
+ var leaves = RecordReflection.getPkLeaves(SealedAnimal.class);
+ assertEquals(1, leaves.size());
+ assertEquals("id", leaves.get(0).field().name());
}
// Multiple table names in @DbTable for sealed entity (L387)
@@ -622,14 +625,13 @@ void testGetTableNameSealedEntityEmptyDbTableFallsBackToCamelCase() throws SqlTe
assertEquals("sealed_empty_db_table", tableName.name());
}
- // getNestedPkFields for type with no PK (L185)
+ // getPkLeaves for type with no PK fails fast
public record NoPkData(String value) implements Data {}
@Test
- void testGetNestedPkFieldsNoPk() {
- List pkFields = RecordReflection.getNestedPkFields(NoPkData.class).toList();
- assertTrue(pkFields.isEmpty());
+ void testGetPkLeavesNoPkThrows() {
+ assertThrows(SqlTemplateException.class, () -> RecordReflection.getPkLeaves(NoPkData.class));
}
// getRefPkType error for non-entity Ref type (L314, L318-324)
diff --git a/storm-h2/src/main/java/st/orm/spi/h2/H2EntityRepositoryImpl.java b/storm-h2/src/main/java/st/orm/spi/h2/H2EntityRepositoryImpl.java
index 2fcfd812e..6b217c3a1 100644
--- a/storm-h2/src/main/java/st/orm/spi/h2/H2EntityRepositoryImpl.java
+++ b/storm-h2/src/main/java/st/orm/spi/h2/H2EntityRepositoryImpl.java
@@ -21,7 +21,6 @@
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
-import java.lang.reflect.RecordComponent;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Timestamp;
@@ -32,16 +31,13 @@
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.ZonedDateTime;
-import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
-import st.orm.Data;
import st.orm.Entity;
-import st.orm.PK;
import st.orm.PersistenceException;
import st.orm.core.repository.EntityRepository;
import st.orm.core.repository.impl.MergeEntityRepositoryImpl;
@@ -108,22 +104,7 @@ public H2EntityRepositoryImpl(@Nonnull ORMTemplate ormTemplate, @Nonnull Model type = column.type();
- if (column.foreignKey()) {
- var secondary = column.secondaryMetamodel();
- if (secondary == null) {
- return null;
- }
- // Foreign key columns store the referenced entity's primary key. For compound keys the foreign key
- // spans multiple columns; keyIndex identifies this column's position within the flattened key.
- var leaves = new ArrayList>();
- flattenKeyTypes(secondary.fieldType(), leaves);
- int index = column.keyIndex();
- if (index < 1 || index > leaves.size()) {
- return null;
- }
- type = leaves.get(index - 1);
- }
+ Class> type = column.persistedType();
String mapped = CAST_TYPES.get(type);
if (mapped != null) {
return mapped;
@@ -137,30 +118,6 @@ protected String castType(@Nonnull Column column) {
return null;
}
- /**
- * Flattens a key type into the Java types of its database columns, in declaration order: records recurse into
- * their components, entity references resolve to their primary key, and simple types are the leaves.
- */
- private static void flattenKeyTypes(@Nonnull Class> type, @Nonnull List> leaves) {
- if (type.isRecord()) {
- if (Data.class.isAssignableFrom(type)) {
- for (RecordComponent component : type.getRecordComponents()) {
- if (component.isAnnotationPresent(PK.class)) {
- flattenKeyTypes(component.getType(), leaves);
- return;
- }
- }
- leaves.add(type); // No primary key found; unmappable.
- return;
- }
- for (RecordComponent component : type.getRecordComponents()) {
- flattenKeyTypes(component.getType(), leaves);
- }
- return;
- }
- leaves.add(type);
- }
-
@Override
protected TemplateString mergeInsert() {
var dialect = ormTemplate.dialect();
diff --git a/storm-h2/src/test/java/st/orm/spi/h2/H2EntityRepositoryTest.java b/storm-h2/src/test/java/st/orm/spi/h2/H2EntityRepositoryTest.java
index edf3dc9e6..e9dc459cf 100644
--- a/storm-h2/src/test/java/st/orm/spi/h2/H2EntityRepositoryTest.java
+++ b/storm-h2/src/test/java/st/orm/spi/h2/H2EntityRepositoryTest.java
@@ -541,6 +541,84 @@ public void testUpsertCompoundForeignKeyAsPrimaryKey() {
assertEquals("second", repo.getById(vetSpecialty).note());
}
+ @Builder(toBuilder = true)
+ @DbTable("vet_specialty_note_audit")
+ public record VetSpecialtyNoteAudit(
+ @Nonnull @PK(generation = NONE) @FK VetSpecialtyNote note, // The referenced key chain is two levels deep.
+ @Nonnull String remark
+ ) implements Entity {}
+
+ @Test
+ public void testCrudNestedCompoundKeyChain() {
+ // The FK resolves through VetSpecialtyNote's PK — the VetSpecialty entity keyed by the compound
+ // VetSpecialtyPK record — flattening to the (vet_id, specialty_id) columns.
+ var noteRepo = PreparedStatementTemplate.ORM(dataSource).entity(VetSpecialtyNote.class);
+ var vetSpecialty = new VetSpecialty(new VetSpecialtyPK(3, 2));
+ noteRepo.upsert(VetSpecialtyNote.builder().vetSpecialty(vetSpecialty).note("base note").build());
+ var note = noteRepo.getById(vetSpecialty);
+
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(VetSpecialtyNoteAudit.class);
+ repo.insert(VetSpecialtyNoteAudit.builder().note(note).remark("created").build());
+ var stored = repo.getById(note);
+ assertEquals("created", stored.remark());
+ assertEquals(vetSpecialty.id(), stored.note().vetSpecialty().id());
+ repo.update(stored.toBuilder().remark("updated").build());
+ assertEquals("updated", repo.getById(note).remark());
+ repo.remove(stored.toBuilder().remark("updated").build());
+ assertTrue(repo.findById(note).isEmpty());
+ }
+
+ @Test
+ public void testUpsertNestedCompoundKeyChain() {
+ var noteRepo = PreparedStatementTemplate.ORM(dataSource).entity(VetSpecialtyNote.class);
+ var vetSpecialty = new VetSpecialty(new VetSpecialtyPK(4, 2));
+ noteRepo.upsert(VetSpecialtyNote.builder().vetSpecialty(vetSpecialty).note("base note").build());
+ var note = noteRepo.getById(vetSpecialty);
+
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(VetSpecialtyNoteAudit.class);
+ repo.upsert(VetSpecialtyNoteAudit.builder().note(note).remark("created").build());
+ assertEquals("created", repo.getById(note).remark());
+ repo.upsert(VetSpecialtyNoteAudit.builder().note(note).remark("revised").build());
+ assertEquals("revised", repo.getById(note).remark());
+ }
+
+ @Builder(toBuilder = true)
+ @DbTable("specialty_note_history")
+ public record SpecialtyNoteHistory(
+ @Nonnull @PK(generation = NONE) @FK SpecialtyNote note, // Single-column key chain, two levels deep.
+ @Nonnull String remark
+ ) implements Entity {}
+
+ @Test
+ public void testUpsertNestedSingleColumnKeyChain() {
+ // The chain SpecialtyNote -> Specialty -> Integer collapses to a single column named after the field.
+ var specialty = PreparedStatementTemplate.ORM(dataSource).entity(Specialty.class).getById(3);
+ var noteRepo = PreparedStatementTemplate.ORM(dataSource).entity(SpecialtyNote.class);
+ noteRepo.upsert(SpecialtyNote.builder()
+ .specialty(specialty)
+ .note("dentistry note")
+ .updatedAt(Instant.parse("2026-01-01T10:00:00Z"))
+ .build());
+ var note = noteRepo.getById(specialty);
+
+ var repo = PreparedStatementTemplate.ORM(dataSource).entity(SpecialtyNoteHistory.class);
+ repo.upsert(SpecialtyNoteHistory.builder().note(note).remark("created").build());
+ assertEquals("created", repo.getById(note).remark());
+ repo.upsert(SpecialtyNoteHistory.builder().note(note).remark("revised").build());
+ assertEquals("revised", repo.getById(note).remark());
+ }
+
+ public record CycleA(@PK(generation = NONE) @FK CycleB other) implements Entity {}
+ public record CycleB(@PK(generation = NONE) @FK CycleA other) implements Entity {}
+
+ @Test
+ public void testCircularKeyChainFailsFast() {
+ // A key chain that references itself cannot be flattened; model construction must fail with a clear
+ // message instead of looping or emitting a broken model.
+ assertThrows(PersistenceException.class, () ->
+ PreparedStatementTemplate.ORM(dataSource).entity(CycleA.class).findAll());
+ }
+
@Builder(toBuilder = true)
@DbTable("pet")
public record Pet(
diff --git a/storm-h2/src/test/resources/data.sql b/storm-h2/src/test/resources/data.sql
index feac60150..e20729bb5 100644
--- a/storm-h2/src/test/resources/data.sql
+++ b/storm-h2/src/test/resources/data.sql
@@ -6,6 +6,8 @@ DROP TABLE IF EXISTS specialty_note CASCADE;
DROP TABLE IF EXISTS vet CASCADE;
DROP TABLE IF EXISTS vet_specialty CASCADE;
DROP TABLE IF EXISTS vet_specialty_note CASCADE;
+DROP TABLE IF EXISTS vet_specialty_note_audit CASCADE;
+DROP TABLE IF EXISTS specialty_note_history CASCADE;
DROP TABLE IF EXISTS visit CASCADE;
DROP VIEW IF EXISTS owner_view;
DROP VIEW IF EXISTS visit_view;
@@ -73,6 +75,20 @@ CREATE TABLE vet_specialty_note (
PRIMARY KEY (vet_id, specialty_id)
);
+-- Dependent one-to-one on vet_specialty_note: the referenced key chain is two levels deep.
+CREATE TABLE vet_specialty_note_audit (
+ vet_id integer NOT NULL,
+ specialty_id integer NOT NULL,
+ remark varchar(255) NOT NULL,
+ PRIMARY KEY (vet_id, specialty_id)
+);
+
+-- Dependent one-to-one on specialty_note: a single-column key chain, two levels deep.
+CREATE TABLE specialty_note_history (
+ note_id integer PRIMARY KEY,
+ remark varchar(255) NOT NULL
+);
+
CREATE TABLE visit (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
visit_date date,
diff --git a/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java b/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java
index 77d806342..eedad1f2a 100644
--- a/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java
+++ b/storm-postgresql/src/main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java
@@ -297,7 +297,7 @@ public void setParameter(@Nonnull PreparedStatement preparedStatement, int index
* @param index the parameter index.
* @param json the serialized JSON value.
* @throws SQLException if a database access error occurs.
- * @since 1.12
+ * @since 1.11
*/
@Override
public void setParameter(@Nonnull PreparedStatement preparedStatement, int index,
diff --git a/website/static/skills/storm-entity-java.md b/website/static/skills/storm-entity-java.md
index 6232fe802..d7d707799 100644
--- a/website/static/skills/storm-entity-java.md
+++ b/website/static/skills/storm-entity-java.md
@@ -92,6 +92,7 @@ Generation rules:
9. Primary key as foreign key (dependent one-to-one, extension tables):
- Use both `@PK(generation = NONE)` and `@FK` on the same field. The entity's type parameter is the related entity type.
+ - Key chains are supported: the referenced entity's primary key may itself be a foreign key or a compound key record. The columns resolve to the chain's terminal key columns. Circular key chains are rejected at model construction.
```java
record UserProfile(@PK(generation = NONE) @FK User user,
@Nullable String bio,
diff --git a/website/static/skills/storm-entity-kotlin.md b/website/static/skills/storm-entity-kotlin.md
index 44e4cf14b..0b2193a4d 100644
--- a/website/static/skills/storm-entity-kotlin.md
+++ b/website/static/skills/storm-entity-kotlin.md
@@ -109,6 +109,7 @@ Generation rules:
9. Primary key as foreign key (dependent one-to-one, extension tables):
- Use both `@PK(generation = NONE)` and `@FK` on the same field. The entity's type parameter is the related entity type.
+ - Key chains are supported: the referenced entity's primary key may itself be a foreign key or a compound key record. The columns resolve to the chain's terminal key columns. Circular key chains are rejected at model construction.
```kotlin
data class UserProfile(
@PK(generation = NONE) @FK val user: User,