@FilterDef(applyToLoadByKey = true) breaks JOIN FETCH of a JOINED-inheritance to-one association: subclass table joins dropped from FROM while their columns remain in SELECT (invalid SQL)

Affected versions

Reproduced on 7.1.16.Final and 7.4.1.Final (identical code paths). Any Hibernate ORM 6.2+ where applyToLoadByKey exists is likely affected.

Environment

  • JPA/HQL query (SQM path). Also reproducible via Criteria root.fetch(…).
  • Any dialect; observed on Oracle (ORA-00904), but the generated SQL is malformed on all dialects.

Description

When a @FilterDef has applyToLoadByKey = true and is active, an HQL/Criteria JOIN FETCH of a @ManyToOne whose target entity uses @Inheritance(InheritanceType.JOINED) generates SQL where the subclass/secondary table joins are missing from the FROM clause, even though those subclass columns are still listed in the SELECT
clause. The database rejects the query because the SELECT references table aliases that are never joined.

entityManager.find(id) and lazy by-id loads of the same entity work correctly (they use the loader path, not the SQM association-fetch path), so this only manifests for query-driven eager association fetches.

Mapping to reproduce

@MappedSuperclass
@FilterDef(
name = "tenantFilter",
applyToLoadByKey = true,                    // <-- required to trigger
defaultCondition = "tenant_id = :tenantId",
parameters = @ParamDef(name = "tenantId", type = Long.class))
public abstract class TenantAware {
@Column(name = "tenant_id")
Long tenantId;
}

// JOINED inheritance root
@Entity
@Table(name = "payment")
@Inheritance(strategy = InheritanceType.JOINED)
@Filter(name = "tenantFilter")
public abstract class Payment extends TenantAware {
@Id @GeneratedValue Long id;
}

@Entity @Table(name = "card_payment")
@Filter(name = "tenantFilter")
public class CardPayment extends Payment {
@Column(name = "card_number") String cardNumber;
}

@Entity @Table(name = "wire_payment")
@Filter(name = "tenantFilter")
public class WirePayment extends Payment {
@Column(name = "iban") String iban;
}

// Owning entity with a LAZY to-one to the JOINED hierarchy
@Entity @Table(name = "wallet")
@Filter(name = "tenantFilter")
public class Wallet extends TenantAware {
@Id @GeneratedValue Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "payment_id")
  Payment payment;

}

Steps to reproduce

session.enableFilter("tenantFilter").setParameter("tenantId", 1L);

// Fails:
session.createQuery(
"SELECT w FROM Wallet w LEFT JOIN FETCH w.payment", Wallet.class)
.getResultList();

// Equivalent Criteria form also fails:
// root.fetch("payment", JoinType.LEFT);

Expected SQL (what you get WITHOUT applyToLoadByKey)

The subclass tables are joined so their columns can be selected:

select w.id, ...,
p.id, cp.card_number, wp.iban
from wallet w
left join payment p
on p.id = w.payment_id
left join card_payment cp on cp.id = p.id -- subclass join present
left join wire_payment wp on wp.id = p.id -- subclass join present
where w.tenant_id = ?

Actual SQL (WITH applyToLoadByKey = true)

The tenant predicate is correctly added to the join, but the subclass table joins are gone while their columns remain selected:

select w.id, ...,
p.id, cp.card_number, wp.iban -- cp., wp. still selected
from wallet w
left join payment p
on p.id = w.payment_id
and p.tenant_id = ? -- filter applied here (correct)
-- MISSING: left join card_payment cp ...
-- MISSING: left join wire_payment wp ...
where w.tenant_id = ?
→ e.g. Oracle: ORA-00904: "CP"."CARD_NUMBER": invalid identifier.

Root-cause analysis

  1. applyToLoadByKey = true makes AbstractEntityPersister.hasFilterForLoadByKey() return true.
  2. In ToOneAttributeMapping.createTableGroupJoin(…), the block guarded by hasFilterForLoadByKey() calls applyBaseRestrictions(…), appending the filter predicate to the association’s join ON-clause. The join predicate becomes a compound predicate instead of a bare col = col.
  3. SimpleForeignKeyDescriptor.isSimpleJoinPredicate(…) now returns false, so in ToOneAttributeMapping.determineTableGroupForFetch(…) the LEFT-join table-group reuse guard (“only reuse if the predicate has no user-defined bits”) rejects the join.
  4. As a result the fetch’s LazyTableGroup is not initialized/realized in the rendered FROM (LazyTableGroup.getTableReferenceJoins() returns empty), so the JOINED subclass table joins are never emitted — but the entity fetch still selects all subclass columns.

Additional notes

  • Works fine when the JOINED entity is the query root (SELECT p FROM Payment p) or loaded by id (em.find(Payment.class, id)) — those go through the loader/root-table-group path, not the association-fetch reuse path.
  • Single-table (SINGLE_TABLE) targets are unaffected (no subclass tables to drop).
  • Reproducible with both HQL JOIN FETCH and Criteria Root.fetch(…).
  • Minimal trigger set: applyToLoadByKey = true + @Inheritance(JOINED) target + eager association fetch in a query.

Thank you for your response.

Here is the unit test class:


package org.hibernate.orm.test.filter;

import java.util.List;

import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;

import org.hibernate.testing.orm.junit.DomainModel;
import org.hibernate.testing.orm.junit.SessionFactory;
import org.hibernate.testing.orm.junit.SessionFactoryScope;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Table;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

/**
 * Reproduces: a @FilterDef with applyToLoadByKey = true breaks JOIN FETCH of a
 * @ManyToOne whose target uses InheritanceType.JOINED.
 *
 * The generated SQL selects the subclass table columns but omits the subclass
 * table joins from the FROM clause, producing invalid SQL (e.g. Oracle ORA-00904;
 * on H2 a "Column ... not found" / general SQL grammar failure).
 *
 * The same query succeeds when applyToLoadByKey is false, and em.find()/lazy
 * by-id loads succeed regardless (they use the loader path, not the SQM
 * association-fetch path).
 */
@DomainModel(annotatedClasses = {
       ApplyToLoadByKeyJoinedInheritanceTest.Wallet.class,
       ApplyToLoadByKeyJoinedInheritanceTest.Payment.class,
       ApplyToLoadByKeyJoinedInheritanceTest.CardPayment.class,
       ApplyToLoadByKeyJoinedInheritanceTest.WirePayment.class
})
@SessionFactory
class ApplyToLoadByKeyJoinedInheritanceTest {

    @BeforeEach
    void setUp(SessionFactoryScope scope) {
       scope.inTransaction( session -> {
          CardPayment payment = new CardPayment();
          payment.tenantId = 1L;
          payment.cardNumber = "4111-1111-1111-1111";
          session.persist( payment );

          Wallet wallet = new Wallet();
          wallet.tenantId = 1L;
          wallet.payment = payment;
          session.persist( wallet );
       } );
    }

    @AfterEach
    void tearDown(SessionFactoryScope scope) {
       scope.getSessionFactory().getSchemaManager().truncateMappedObjects();
    }

    @Test
    void joinFetchOfJoinedInheritanceAssociationWithLoadByKeyFilter(SessionFactoryScope scope) {
       scope.inTransaction( session -> {
          session.enableFilter( "tenantFilter" ).setParameter( "tenantId", 1L );

          // FAILS with applyToLoadByKey = true: subclass table joins (card_payment,
          // wire_payment) are dropped from FROM while their columns stay in SELECT.
          List<Wallet> wallets = session.createQuery(
                "select w from Wallet w left join fetch w.payment", Wallet.class )
                .getResultList();

          assertEquals( 1, wallets.size() );
          Wallet wallet = wallets.get( 0 );
          assertNotNull( wallet.payment );
          assertEquals( "4111-1111-1111-1111", ( (CardPayment) wallet.payment ).cardNumber );
       } );
    }

    @Test
    void findByIdWorks(SessionFactoryScope scope) {
       // Control: the loader (by-id) path is unaffected by the bug.
       Long paymentId = scope.fromTransaction( session -> session.createQuery(
             "select w.payment.id from Wallet w", Long.class ).getSingleResult() );

       scope.inTransaction( session -> {
          session.enableFilter( "tenantFilter" ).setParameter( "tenantId", 1L );
          Payment payment = session.find( Payment.class, paymentId );
          assertNotNull( payment );
       } );
    }

    @MappedSuperclass
    @FilterDef(
          name = "tenantFilter",
          applyToLoadByKey = true, // remove / set false -> the JOIN FETCH test passes
          defaultCondition = "tenant_id = :tenantId",
          parameters = @ParamDef(name = "tenantId", type = Long.class))
    public abstract static class TenantAware {
       @Column(name = "tenant_id")
       Long tenantId;
    }

    @Entity(name = "Payment")
    @Table(name = "payment")
    @Inheritance(strategy = InheritanceType.JOINED)
    @Filter(name = "tenantFilter")
    public abstract static class Payment extends TenantAware {
       @Id
       @GeneratedValue
       Long id;
    }

    @Entity(name = "CardPayment")
    @Table(name = "card_payment")
    @Filter(name = "tenantFilter")
    public static class CardPayment extends Payment {
       @Column(name = "card_number")
       String cardNumber;
    }

    @Entity(name = "WirePayment")
    @Table(name = "wire_payment")
    @Filter(name = "tenantFilter")
    public static class WirePayment extends Payment {
       @Column(name = "iban")
       String iban;
    }

    @Entity(name = "Wallet")
    @Table(name = "wallet")
    @Filter(name = "tenantFilter")
    public static class Wallet extends TenantAware {
       @Id
       @GeneratedValue
       Long id;

       @ManyToOne(fetch = FetchType.LAZY)
       @JoinColumn(name = "payment_id")
       Payment payment;
    }
}

Hello and thank you for the details and test case. Can you please create a bug ticket in our issue tracker and attach that reproducer or provide a PR with the reproducer?

Hello thank you for the reply. I am not able to create the bug ticket in the Jira so that is the reason I created the topic here.

Thank you

Hey, there’s a known issue with Jira .. see this thread on how to deal with this: #hibernate-user > Jira issue creation @ :speech_balloon:

thank you, I was able to create the bug ticket now. Thank you