spring-projects / spring-projects/spring-data-jpa

Unnecessary JOIN for @JoinColumn(referencedColumnName) with where condition by FK

Open
#4,332 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: waiting-for-triage
Dominant language
Java
Stars
3.3k
Forks
1.6k
PR merge metrics
No merged PRs in 30d

Description

Derived query method suddenly generates a JOIN instead of filtering on the FK column directly

Repro environment

  • Spring Data JPA 3.2.1 – 4.x (observed on 3.2.1 and 4.1.0); regression introduced in 3.2.1
    (Spring Boot 3.2.1), NOT in 4.0.x.
  • Spring Boot 3.1.x (Spring Data JPA 3.1.x) and Spring Data JPA 3.2.0 do NOT show this.
  • H2 used for the repro; the behaviour is provider-independent (generated by Spring Data itself).

Summary

When a repository method name derives a query that navigates an association whose
@JoinColumn uses a non-default referencedColumnName, Spring Data JPA >= 3.2.1 generates
a SQL JOIN to evaluate the predicate. Versions up to 3.2.0 generated a plain
WHERE <fk_column> = ? with no JOIN.

The FK column physically stores a copy of the referenced column's value, so the JOIN
is redundant and can hurt performance on hot paths.

Minimal reproducer

Entities

@Entity
@Table(name = "abonents")
public class Abonent {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    @Column(name = "code", unique = true)
    String code;
}

@Entity
@Table(name = "contracts")
public class Contract {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "abonent", referencedColumnName = "code")
    Abonent abonent;
}

Repository

public interface ContractRepository extends JpaRepository<Contract, Long> {
    List<Contract> findByAbonentCode(String abonentCode);
}

The derived path is abonent.code. In Spring Data JPA <= 3.2.0 the comparison is made
against the FK column contracts.abonent directly. From 3.2.1 the derived-query engine
emits an explicit JOIN (verified on 3.2.1 / 3.2.5 / 4.x).

Generated SQL, Spring Data JPA 3.1.2 (old behaviour)
select c1_0.id, c1_0.abonent, c1_0.number
from contracts c1_0
where c1_0.abonent = ?        -- no JOIN
Generated SQL, Spring Data JPA 3.2.1+ (current behaviour; same in 4.1.0)
select c1_0.id, c1_0.abonent, c1_0.number
from contracts c1_0
left join abonents a1_0 on a1_0.code = c1_0.abonent
where a1_0.code = ?

Why it is a regression

  • The regression point is Spring Data JPA 3.2.1 (Spring Boot 3.2.1): 3.1.x and 3.2.0 are
    join-free. In the same 3.2.1 an explicit @Query("... where c.abonent.code = :code")
    is rendered join-free by Hibernate, confirming the JOIN is introduced by the derived-query
    (query-by-name) engine, not by Hibernate (nor by the Hibernate 6.3 -> 6.4 upgrade that
    landed in the same release).
  • contracts.abonent physically stores the code value (it is declared via
    referencedColumnName = "code"). Thus where c1_0.abonent = ? and
    where a1_0.code = ? are semantically identical, but the latter forces a join.
  • We have many such lookups on hot endpoints; the extra join on every request is
    measurable overhead and not removable without rewriting queries.
  • No configuration flag exists in 3.2.x/4.x to restore the old behaviour (we searched
    JpqlQueryBuilder, JpaQueryCreator, PartTreeJpaQuery and the properties docs).

Requested behaviour

Restore the previous join-free behaviour, made configurable so that the cases that
deliberately rely on explicit joins (composite keys, #3349/#3588) are preserved by default.

A PR implementing this as a default-off option exists:

  • Property ${spring.data.jpa.derived-query.prefer-fk-column} (via EntityManagerFactory properties),
    true renders the implicit dotted path (no explicit JOIN), letting the persistence provider collapse
    the predicate onto the foreign-key column — restoring the pre-3.2.1 SQL (WHERE contracts.abonent = ?).
  • Default is false (explicit joins kept), preserving #3349/#3588.

Independent check (reference)

Cross-checked on the same model:

  • Spring Data JPA 3.1.2 -> where contracts.abonent = ?
  • Spring Data JPA 3.2.0 -> where contracts.abonent = ? (still join-free)
  • Spring Data JPA 3.2.1 -> explicit left join in the derived-query JPQL (regression point)
  • Spring Data JPA 4.1.0 -> explicit left join in the derived-query JPQL
  • Spring Data JPA 4.2.0-SNAPSHOT + prefer-fk-column=true -> where contracts.abonent = ?

Both statements were verified against a real H2 database. An explicit @Query("... where c.abonent.code = :code")
is rendered join-free by Hibernate in all versions, confirming the join is introduced by the
derived-query (query-by-name) engine, not by Hibernate.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with JpqlQueryBuilder, JpaQueryCreator, and PartTreeJpaQuery, which the issue identifies as the derived-query engine involved in the regression. Reproduce the entity and repository example, compare generated SQL across the cited versions, and verify that the requested configurable behavior preserves the default handling for #3349/#3588.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spring
Domain
backend, database
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.