From b0790099b5cb6ebff95bc2b49cf4336589560f0c Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 3 Jul 2026 22:04:52 +0200 Subject: [PATCH 1/4] feat: follow key chains when resolving foreign key columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A foreign key to an entity whose primary key is itself an entity reference resolved to a single _id column instead of the referenced key's columns, breaking every operation on such entities. The key structure was derived independently — and inconsistently — by column naming, column typing and value extraction. The key chain is now reified in one place: RecordReflection.getPkLeaves flattens a primary key into its terminal fields, one per database column, each carrying the accessor path from the declaring table. Foreign keys that are primary keys recurse into the referenced entity's key, record keys contribute their components, and circular chains or missing keys fail fast at model construction with a clear message. Column naming, column emission and value extraction all consume the flattened leaves. Column gains persistedType() — the Java type of the value as it is persisted to the column (the chain's terminal type for foreign keys, the converter parameter type for converter-backed columns) — so dialects no longer need any knowledge of key structure: the H2 MERGE cast resolution reduces to a type lookup. ModelImpl precomputes per-column accessor paths at model construction, so per-row extraction is a plain loop of accessor invocations. RecordMapper's early cache lookup now only constructs the key directly when the key record is flat; chained keys are built through the regular argument plan. New H2 coverage: full CRUD and MERGE upserts on a two-level compound key chain, a single-column two-level chain, and fail-fast on circular key chains. --- .../java/st/orm/core/template/Column.java | 16 +++ .../st/orm/core/template/impl/ColumnImpl.java | 1 + .../orm/core/template/impl/ModelFactory.java | 23 ++- .../st/orm/core/template/impl/ModelImpl.java | 122 +++++++++++++--- .../orm/core/template/impl/RecordMapper.java | 8 +- .../core/template/impl/RecordReflection.java | 134 ++++++++++++++---- .../template/impl/RecordReflectionTest.java | 50 +++---- .../st/orm/spi/h2/H2EntityRepositoryImpl.java | 45 +----- .../st/orm/spi/h2/H2EntityRepositoryTest.java | 78 ++++++++++ storm-h2/src/test/resources/data.sql | 16 +++ 10 files changed, 375 insertions(+), 118 deletions(-) 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/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) 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 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/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, From 278d74302ce6ef4f9a56a9d0bbf677582ef3062c Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 3 Jul 2026 22:04:55 +0200 Subject: [PATCH 2/4] docs: correct @since tags to 1.11 --- .../st/orm/core/repository/impl/MergeEntityRepositoryImpl.java | 2 +- storm-core/src/main/java/st/orm/core/spi/JsonString.java | 2 +- storm-core/src/main/java/st/orm/core/template/SqlDialect.java | 2 +- .../main/java/st/orm/spi/postgresql/PostgreSQLSqlDialect.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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-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, From 36a36e92587cc952e43c802187539846a57d28ab Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 3 Jul 2026 22:08:14 +0200 Subject: [PATCH 3/4] feat: validate foreign key columns against the persisted type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema validation checked foreign key columns against the declared entity type, which the type compatibility map does not know — the check was silently skipped. Validating against the persisted type checks the referenced key's terminal type against the database column, following key chains. --- .../java/st/orm/core/template/impl/SchemaValidator.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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. From 7058c32accda1c438e9584e5fba87fb518a307d0 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Fri, 3 Jul 2026 22:08:17 +0200 Subject: [PATCH 4/4] docs: document key chains for primary-key-as-foreign-key entities --- docs/relationships.md | 28 ++++++++++++++++++++ website/static/skills/storm-entity-java.md | 1 + website/static/skills/storm-entity-kotlin.md | 1 + 3 files changed, 30 insertions(+) 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/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,