Constructing a Full-Outer-Join Style View Across Two Entities with Spring Data JPA
When two tables are related but you either don’t model the association or cannot rely on right joins through the Criteria API, you can still project a full "view" by composing three queries: interscetion (inner join), left-only, and right-only. The union of these results behaves like a full outer join.
Below is a minimal example that avoids mapping an association and instead stitches results in the service layer.
Entities
import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
@Entity
@Table(name = "tb_user")
public class UserAccount implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String fullName;
private Integer age;
private String gender;
private String email;
private Date birthDate;
private String phone;
private String degree;
private String almaMater;
// Foreign key kept as a scalar (no @ManyToOne mapping)
private Long addressId;
// getters and setters
}
import javax.persistence.*;
import java.io.Serializable;
@Entity
@Table(name = "tb_address")
public class AddressEntry implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String areaCode;
private String country;
private String province;
private String city;
private String district;
private String street;
// Optional: if you also keep a reverse reference
private Long userId;
// getters and setters
}
Projection (View Model)
A constructor-projection DTO that can represent any of the three cases: both sides present, only user present, or only address present.
import java.io.Serializable;
public class UserAddressView implements Serializable {
private UserAccount user;
private AddressEntry address;
public UserAddressView() {}
public UserAddressView(UserAccount user, AddressEntry address) {
this.user = user;
this.address = address;
}
public UserAddressView(UserAccount user) {
this.user = user;
this.address = new AddressEntry();
}
public UserAddressView(AddressEntry address) {
this.user = new UserAccount();
this.address = address;
}
// getters and setters
}
Repositories
Use JPQL constructor expressions to build the projection. We express the inner join with two root entities and a WHERE clause. For the left- and right-only sets, prefer NOT EXISTS to avoid null-related pitfalls with NOT IN.
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
public interface UserAccountRepository extends CrudRepository<UserAccount, Long> {
// Intersection (equivalent to INNER JOIN on u.addressId = a.id)
@Query("select new com.example.view.UserAddressView(u, a)\n" +
"from UserAccount u, AddressEntry a\n" +
"where u.addressId = a.id")
List<UserAddressView> fetchIntersect();
// Users without a matching address (left anti-join)
@Query("select new com.example.view.UserAddressView(u)\n" +
"from UserAccount u\n" +
"where u.addressId is null\n" +
" or not exists (select 1 from AddressEntry a where a.id = u.addressId)")
List<UserAddressView> fetchLeftOnly();
}
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import java.util.List;
public interface AddressEntryRepository extends CrudRepository<AddressEntry, Long> {
// Addresses without a matching user (right anti-join)
@Query("select new com.example.view.UserAddressView(a)\n" +
"from AddressEntry a\n" +
"where not exists (select 1 from UserAccount u where u.addressId = a.id)")
List<UserAddressView> fetchRightOnly();
}
Service Layer Composition
Query all three parts and union them in memory to emulate a full outer join. The result set then contains:
- inner: matched user–address rows
- left-only: users with no address
- right-only: addresses with no user
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
public class DirectoryService {
private final UserAccountRepository userRepo;
private final AddressEntryRepository addressRepo;
public DirectoryService(UserAccountRepository userRepo, AddressEntryRepository addressRepo) {
this.userRepo = userRepo;
this.addressRepo = addressRepo;
}
@Transactional(readOnly = true)
public List<UserAddressView> fullView() {
List<UserAddressView> union = new ArrayList<>();
List<UserAddressView> intersect = userRepo.fetchIntersect();
List<UserAddressView> leftOnly = userRepo.fetchLeftOnly();
List<UserAddressView> rightOnly = addressRepo.fetchRightOnly();
union.addAll(intersect);
union.addAll(leftOnly);
union.addAll(rightOnly);
return union;
}
@Transactional(readOnly = true)
public void printView() {
System.out.println("=== intersect ===");
for (UserAddressView row : userRepo.fetchIntersect()) {
System.out.println(row.getUser().getUsername() + " " + row.getAddress().getCity());
}
System.out.println("=== left-only ===");
for (UserAddressView row : userRepo.fetchLeftOnly()) {
System.out.println(row.getUser().getUsername() + " " + row.getAddress().getCity());
}
System.out.println("=== right-only ===");
for (UserAddressView row : addressRepo.fetchRightOnly()) {
System.out.println(row.getUser().getUsername() + " " + row.getAddress().getCity());
}
}
}
Notes
- The Criteria/Specification API typically exposes left joins but not right joins; a full outer join is not directly provided. The three-query approach is portable JPQL and works with out mapping associations.
- If your database supports FULL OUTER JOIN or UNION ALL, a single native query can also achieve the same outcome, but the above stays provider-agnostic JPQL.
- Using NOT EXISTS avoids edge cases caused by NULL semantics with NOT IN.
- Constructor expressions are efficeint for proejction: only the required columns are materialized rather than entire managed graphs.