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) 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/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,