在JPA中,使用实体图查询具有自定义中间表的多对多实体

大家好哇!不知道大家在使用JPA的时候有没有试用过实体图(EntityGraph)进行查询呢?它的功能很强大,
比如可以用来规避大家经常遇到的 n+1 的问题。
那么你了不了解如何在"自定义中间表"的情况下去使用实体图查询呢?如果不太了解,那么就来跟着我看一下吧!

为了方便演示,后续的项目环境默认视为在 spring-boot-starter-data-jpa 中喔~

实体定义

首先,我们先来看看普通的多对多实体是如何定义、查询的。假设:我们有一个账户(account)和权限(permission)表,
它们之间是多对多关系,中间表为 account_permission

那么,先来定义它们的实体类:

// Account.java

@Entity
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(nullable = false)
    private String name;

    @ManyToMany
    @JoinTable(name = "account_permission")
    private Set<Permission> permissions = new HashSet<>();
}

// Permission.java

@Entity
public class Permission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    @ManyToMany(mappedBy = "permissions")
    private Set<Account> accounts = new HashSet<>();
}

为了演示省事儿,省略掉 getter、setter 之类的东西咯。

然后,在 Account 中添加实体图描述。

@Entity
@NamedEntityGraph(
        name = Account.ALL_GRAPH,
        attributeNodes = {
                @NamedAttributeNode("permissions")
        }
)
public class Account {
    public static final String ALL_GRAPH = "ACCOUNT.ALL";

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(nullable = false)
    private String name;

    @ManyToMany
    @JoinTable(name = "account_permission")
    private Set<Permission> permissions = new HashSet<>();
}

接下来,定义一个 Repository 并使用图查询,来看看效果:

public interface AccountRepository extends JpaRepository<Account, Integer> {
    // 使用具有命名的图查询
    @EntityGraph(Account.ALL_GRAPH)
    List<Account> findAllBy();
}
@SpringBootTest(classes = {GraphTests.class, AccountRepository.class})
@ActiveProfiles("test")
@SpringBootApplication
public class GraphTests {
    @Test
    public void graphTest(
            @Autowired AccountRepository accountRepository
    ) {
        accountRepository.findAllBy();
    }
}

当执行测试之后,我们便可以在日志中看到本次查询所生成的SQL:

select
    a1_0.id,
    a1_0.name,
    p1_0.accounts_id,
    p1_1.id,
    p1_1.name
from
    account a1_0
left join
    (account_permission p1_0
join
    permission p1_1
        on p1_1.id=p1_0.permissions_id)
    on a1_0.id=p1_0.accounts_id

可以看到,它帮我们自动进行了关联查询,避免了n+1的情况出现。

自定义中间表

有些时候,我们希望自定义中间表的内容,比如增加 created_date 来记录关联双方的创建日期。这时候就需要修改一下我们的几个实体类,并添加一个 AccountPermission

// Account.java

@Entity
@NamedEntityGraph(
        name = Account.ALL_GRAPH,
        attributeNodes = {
                @NamedAttributeNode("permissions")
        }
)
public class Account {
    public static final String ALL_GRAPH = "ACCOUNT.ALL";

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(nullable = false)
    private String name;

    @OneToMany(mappedBy = "id.account")
    private Set<AccountPermission> permissions = new HashSet<>();
}

// Permission.java

@Entity
public class Permission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    @OneToMany(mappedBy = "id.permission")
    private Set<AccountPermission> accounts = new HashSet<>();
}

// AccountPermission.java

@Entity
@Table(name = "account_permission")
public class AccountPermission {
    @EmbeddedId
    private PrimaryKey id = new PrimaryKey();

    @CreatedDate
    private Instant createdDate;

    @Embeddable
    public static class PrimaryKey {
        @ManyToOne
        private Account account;
        @ManyToOne
        private Permission permission;
    }
}

可是这时候再去执行 findAllBy 的时候,你会发现生成的SQL出现了一些变化。

@SpringBootTest(classes = {GraphTests.class, AccountRepository.class})
@ActiveProfiles("test")
@SpringBootApplication
@EnableJpaAuditing
public class GraphTests {
    @Autowired
    EntityManager entityManager;
    @Autowired
    AccountRepository accountRepository;
    @Autowired
    PermissionRepository permissionRepository;
    @Autowired
    AccountPermissionRepository accountPermissionRepository;

    @Test
    @Transactional
    public void graphTest() {
        initData(); // 初始化2个 Account、4个 Permission

        final var all = accountRepository.findAllBy();
        for (var account : all) {
            System.out.println("account: " + account.getName());
            for (var permission : account.getPermissions()) {
                System.out.println("Account.permission: " + permission.id.getPermission().getName());
            }
            System.out.println();
        }
    }

    private void initData() {
        // ... 生成2个 Account、4个 Permission,
        // 并使它们之间存在关联:每一个account关联两个permission。
    }
}

而控制台产生的日志为:

Hibernate:
    select
        a1_0.id,
        a1_0.name,
        p1_0.account_id,
        p1_0.permission_id,
        p1_0.created_date
    from
        account a1_0
    left join
        account_permission p1_0
            on a1_0.id=p1_0.account_id
account: Account1
Hibernate:
    select
        p1_0.id,
        p1_0.name
    from
        permission p1_0
    where
        p1_0.id=?
Account.permission: Permission2
Hibernate:
    select
        p1_0.id,
        p1_0.name
    from
        permission p1_0
    where
        p1_0.id=?
Account.permission: Permission1

account: Account1
Hibernate:
    select
        p1_0.id,
        p1_0.name
    from
        permission p1_0
    where
        p1_0.id=?
Account.permission: Permission3
Hibernate:
    select
        p1_0.id,
        p1_0.name
    from
        permission p1_0
    where
        p1_0.id=?
Account.permission: Permission4

这下糟糕了!你心里想着。因为很明显,n+1的问题又出现了!

一些尝试

究其原因,我们在 AccountPermission 中的关联由 ManyToMany 变为了针对中间表的 OneToMany
因此 Account 上的 @NamedEntityGraph 便只会抓取中间表,而不是中间表对应的另一个关联表。

那么我们应该如何修改图定义呢?如果改成下面这样…

@NamedEntityGraph(
        name = Account.ALL_GRAPH,
        attributeNodes = {
                @NamedAttributeNode(value = "permissions", subgraph = "sub")
        },
        subgraphs = {
                @NamedSubgraph(
                        name = "sub",
                        attributeNodes = @NamedAttributeNode(value = "id")
                )
        }
)

我们再添加一个子图,用来抓取中间表的 id ,如何?
但是很遗憾,结果并不会有什么区别。

那如果是:

@NamedEntityGraph(
        name = Account.ALL_GRAPH,
        attributeNodes = {
                @NamedAttributeNode(value = "permissions", subgraph = "sub")
        },
        subgraphs = {
                @NamedSubgraph(
                        name = "sub",
                        attributeNodes = @NamedAttributeNode(value = "id", subgraph = "id-permission")
                ),
                @NamedSubgraph(
                        name = "id-permission",
                        attributeNodes = @NamedAttributeNode(value = "permission")
                ),
        }
)

再加一层,抓取id中明确的 permission 属性呢?
可是 Permission.PrimaryKey 类型本身其实不算是实体类,这样的结构其实是无效的。

那么如此又如何:

@NamedEntityGraph(
        name = Account.ALL_GRAPH,
        attributeNodes = {
                @NamedAttributeNode(value = "permissions", subgraph = "sub")
        },
        subgraphs = {
                @NamedSubgraph(
                        name = "sub",
                        attributeNodes = @NamedAttributeNode(value = "id.permission")
                )
        }
)

但是很抱歉,这种路径结构的方式是并不受支持的。

再次改进

那么该怎么做才能真正关联到中间表对应的另一侧的关联实体呢?
首先,我们需要先修改一下中间表的定义。

@Entity
@Table(name = "account_permission")
public class AccountPermission {
    @EmbeddedId
    PrimaryKey id = new PrimaryKey();

    @ManyToOne
    @MapsId("accountId")
    private Account account;

    @ManyToOne
    @MapsId("permissionId")
    private Permission permission;

    @CreatedDate
    Instant createdDate;

    @Embeddable
    public static class PrimaryKey {
        private Integer accountId;
        private Integer permissionId;
    }
}

可以看到,PrimaryKey 中我们只留下了两个ID类型,而真正具有关联性的实体对象则挪到了外层,并使用 @MapsId 进行了标记。
此时,我们再来修改 Account 上的图定义:

@NamedEntityGraph(
        name = Account.ALL_GRAPH,
        attributeNodes = {
                @NamedAttributeNode(value = "permissions", subgraph = "sub")
        },
        subgraphs = {
                @NamedSubgraph(
                        name = "sub",
                        attributeNodes = @NamedAttributeNode(value = "permission")
                )
        }
)

调整两个实体中的 OneToMany 注解上的 mappedBy

// Account.java
public class Account {
    public static final String ALL_GRAPH = "ACCOUNT.ALL";

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(nullable = false)
    private String name;

    @OneToMany(mappedBy = "account")
    private Set<AccountPermission> permissions = new HashSet<>();
}

// Permission.java
@Entity
public class Permission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    @OneToMany(mappedBy = "permission")
    private Set<AccountPermission> accounts = new HashSet<>();
}

然后,运行我们的测试方法:

@Test
@Transactional
public void graphTest() {
    initData(); // 初始化2个 Account、4个 Permission

    final var all = accountRepository.findAllBy();
    for (var account : all) {
        System.out.println("account: " + account.getName());
        for (var permission : account.getPermissions()) {
            System.out.println("Account.permission: " + permission.getPermission().getName());
        }
        System.out.println();
    }
}

此时观察日志:

Hibernate:
    select
        a1_0.id,
        a1_0.name,
        p1_0.account_id,
        p1_0.permission_id,
        p1_0.created_date,
        p2_0.id,
        p2_0.name
    from
        account a1_0
    left join
        account_permission p1_0
            on a1_0.id=p1_0.account_id
    left join
        permission p2_0
            on p2_0.id=p1_0.permission_id
account: Account1
Account.permission: Permission1
Account.permission: Permission2

account: Account1
Account.permission: Permission4
Account.permission: Permission3

你会发现,生成的SQL中该有的表关联都回来了,n+1的问题也不见了,可喜可贺,可喜可贺~

结尾

如此,本篇文章就接近尾声了。不知道有没有对你起到帮助呢?如果有,点赞收藏或在评论区表扬一下我吧~

  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值