From 6949838d7e4aa6dee32d7a1fa6bae6d513e90924 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 3 Aug 2026 13:58:26 +0200 Subject: [PATCH] fix(core): resolve navigation-only nodes in template fragments --- .../orm/core/template/impl/QueryModelImpl.java | 16 ++++++++++++++-- .../core/template/impl/TemplatePreparation.java | 4 ++++ .../core/RefGraphTraversalIntegrationTest.java | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java index ef985319c..62c49be64 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryModelImpl.java @@ -40,6 +40,7 @@ import st.orm.Discriminator.DiscriminatorType; import st.orm.Element; import st.orm.Metamodel; +import st.orm.Navigable; import st.orm.Operator; import st.orm.Ref; import st.orm.SelectMode; @@ -612,12 +613,23 @@ private Object resolveElements(@Nullable Object value) throws SqlTemplateExcepti case TemplateString ignore -> throw new SqlTemplateException("TemplateString is not allowed as a string template value."); case Stream ignore -> throw new SqlTemplateException("Stream is not supported as a string template value. Collect the Stream into a List before passing it."); case Subqueryable t -> new Subquery(t.getSubquery(), true); - case Metamodel m when m.isColumn() -> new st.orm.core.template.impl.Elements.Column(m, CASCADE); - case Metamodel ignore -> throw new SqlTemplateException("Metamodel does not reference a column. Use a column-level metamodel (e.g., User_.name) rather than a table-level metamodel."); + case Navigable m when m.isColumn() -> new Elements.Column(toColumnMetamodel(m), CASCADE); + case Navigable ignore -> throw new SqlTemplateException("Path does not reference a column. Use a column-level path (e.g., User_.name) rather than a table-level path."); case null, default -> value; }; } + /** + * Resolves a navigable used as a template value into a column metamodel. Full metamodels are used as-is; a + * navigation-only node (one that navigates beyond a {@link Ref}) is rebuilt into a resolvable metamodel for its + * path so it can be selected or filtered. The rebuilt metamodel is query-only and cannot extract a value. + */ + private static Metamodel toColumnMetamodel(@Nonnull Navigable navigable) { + return navigable instanceof Metamodel metamodel + ? metamodel + : Metamodel.of(navigable.root(), navigable.fieldPath()); + } + /** * Resolves the {@link Model} instance corresponding to the given metamodel. * diff --git a/storm-core/src/main/java/st/orm/core/template/impl/TemplatePreparation.java b/storm-core/src/main/java/st/orm/core/template/impl/TemplatePreparation.java index d15d848d5..8928056b7 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/TemplatePreparation.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/TemplatePreparation.java @@ -1208,6 +1208,10 @@ private void collectReferencedTablePaths(@Nonnull Class rootTabl for (var value : template.values()) { switch (value) { case Metamodel metamodel -> addReferencedTablePath(rootTable, metamodel, paths); + // A navigation-only node (one that navigates beyond a Ref) is rebuilt into a resolvable metamodel, + // so the joins beyond the reference are derived for it. + case Navigable navigable when navigable.isColumn() -> + addReferencedTablePath(rootTable, toColumnMetamodel(navigable), paths); case Expression expression -> collectReferencedTablePaths(rootTable, expression, paths, tables, hydratedTables, hydratedPaths); case Element element -> collectReferencedTablePaths(rootTable, element, paths, tables, hydratedTables, hydratedPaths); diff --git a/storm-core/src/test/java/st/orm/core/RefGraphTraversalIntegrationTest.java b/storm-core/src/test/java/st/orm/core/RefGraphTraversalIntegrationTest.java index a343822ee..0ae13bcbc 100644 --- a/storm-core/src/test/java/st/orm/core/RefGraphTraversalIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/RefGraphTraversalIntegrationTest.java @@ -151,6 +151,21 @@ public void testTypedMetamodelFilterThroughRef() { assertEquals(viaEntity, viaTypedRef); } + @Test + public void testFilterThroughRefInWhereTemplate() { + var orm = ORMTemplate.of(dataSource); + // A navigation-only node interpolated into a where template fragment must resolve to a column with its + // joins, exactly like the predicate form, rather than degrade to a bind parameter. + List viaPredicate = orm.entity(PetOwnerRef.class).select() + .where(PetOwnerRef_.owner.address.city.name, EQUALS, "Madison") + .getResultList().stream().map(PetOwnerRef::id).sorted().toList(); + List viaTemplate = orm.entity(PetOwnerRef.class).select() + .where(raw("\0 = \0", PetOwnerRef_.owner.address.city.name, "Madison")) + .getResultList().stream().map(PetOwnerRef::id).sorted().toList(); + assertFalse(viaPredicate.isEmpty()); + assertEquals(viaPredicate, viaTemplate); + } + @Test public void testBeyondRefNodeIsNavigableOnly() { // The reference node itself is a value metamodel (getValue returns the Ref, so getResultGroupedByRef works),