1. 概述

在使用Spring Data操作数据库时,我们经常遇到某些场景:并非实体中的所有字段在每次操作中都是必需的。因此,我们可能希望让结果集中的某些字段变为可选。

本文将探讨使用Spring Data和原生查询时,从数据库查询中仅获取所需列的不同技术方案。

2. 为什么需要可选字段?

在结果集中实现字段可选的需求,源于平衡数据完整性与性能的必要性。在许多应用中(尤其是具有复杂数据模型的应用),获取整个实体会导致不必要的开销,特别是当某些字段与特定上下文或操作无关时。通过从结果集中排除非必要字段,我们可以最小化处理和传输的数据量,从而加快查询速度并降低内存消耗

在SQL层面可以直观看到这一点。例如,从book表获取数据:

select * from book where id = 1;

假设book表有十列。如果其中某些列不需要,我们可以提取子集:

select id, title, author from book where id = 1;

3. 示例环境搭建

为演示效果,我们创建一个Spring Boot应用。假设需要从数据库获取书籍列表,定义Book实体:

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

    @Column
    private String title;

    @Column
    private String author;

    @Column
    private String synopsis;

    @Column
    private String language;

    // 其他字段、getter和setter
}

使用H2内存数据库,创建application-h2.properties配置文件:

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update

启动Spring Boot应用,并指定加载配置文件:

@Profile("h2")
@SpringBootApplication
public class OptionalFieldsApplication {
    public static void main(String[] args) {
        SpringApplication.run(OptionalFieldsApplication.class);
    }
}

为每种解决方案创建@Repository接口。预先设置测试类并激活h2配置:

@ActiveProfiles("h2")
@SpringBootTest(classes = OptionalFieldsApplication.class)
@Transactional
public class OptionalFieldsUnitTest {
    // 仓库的@Autowired和测试方法
}

注意:我们使用@Transactional注解,每个测试后回滚,确保测试实例间干净隔离。

接下来看如何让Book的某些字段变为可选。例如,我们不需要所有详情,只需获取idtitleauthor即可。

4. 使用投影

在SQL中,投影指从表或查询中选择特定列或字段,而非获取全部数据,从而限制我们获取的数据量

我们可以将投影定义为类或接口:

public interface BookProjection {
    Integer getId();
    String getTitle();
    String getAuthor();
}

定义仓库接口提取投影:

@Repository
public interface BookProjectionRepository extends JpaRepository<Book, Integer> {
    @Query(value = "SELECT b.id as id, b.title, b.author FROM Book b", nativeQuery = true)
    List<BookProjection> fetchBooks();
}

BookProjectionRepository编写简单测试:

@Test
public void whenUseProjection_thenFetchOnlyProjectionAttributes() {
    String title = "Title Projection";
    String author = "Author Projection";

    Book book = new Book();
    book.setTitle(title);
    book.setAuthor(author);
    bookProjectionRepository.save(book);

    List<BookProjection> result = bookProjectionRepository.fetchBooks();

    assertEquals(1, result.size());
    assertEquals(title, result.get(0).getTitle());
    assertEquals(author, result.get(0).getAuthor());
}

获取BookProjection对象后,可断言titleauthor是我们持久化的值。

5. 使用DTO

DTO(数据传输对象)是应用不同层或组件间(通常在数据库层与业务逻辑/服务层间)传输数据的简单对象。

此处我们用它创建包含所需字段的数据集对象,从数据库获取时仅填充DTO的字段。定义BookDto

public record BookDto(Integer id, String title, String author) {}

定义仓库接口提取DTO对象:

@Repository
public interface BookDtoRepository extends JpaRepository<Book, Integer> {
    @Query(value = "SELECT new com.baeldung.spring.data.jpa.optionalfields.BookDto(b.id, b.title, b.author) FROM Book b")
    List<BookDto> fetchBooks();
}

这里使用JPQL语法为每条图书记录创建BookDto实例。

最后添加验证测试:

@Test
public void whenUseDto_thenFetchOnlyDtoAttributes() {
    String title = "Title Dto";
    String author = "Author Dto";

    Book book = new Book();
    book.setTitle(title);
    book.setAuthor(author);
    bookDtoRepository.save(book);

    List<BookDto> result = bookDtoRepository.fetchBooks();

    assertEquals(1, result.size());
    assertEquals(title, result.get(0).title());
    assertEquals(author, result.get(0).author());
}

6. 使用@SqlResultSetMapping

可将@SqlResultSetMapping注解作为DTO或投影的替代方案。使用时需在实体类上应用该注解:

@Entity
@Table
@SqlResultSetMapping(name = "BookMappingResultSet", 
  classes = @ConstructorResult(targetClass = BookDto.class, columns = {
      @ColumnResult(name = "id", type = Integer.class), 
      @ColumnResult(name = "title", type = String.class),
      @ColumnResult(name = "author", type = String.class) }))
public class Book {
    // 同初始设置
} 

需要@ConstructorResult@ColumnResult标识结果集。注意:本例结果集使用相同类定义,因此可复用BookDto类(因其构造器匹配)。

@SqlResultSetMapping不能与@Query一起使用,因此仓库需额外处理(使用EntityManager)。首先创建自定义仓库接口:

public interface BookCustomRepository {
    List<BookDto> fetchBooks();
}

该接口包含所需方法签名,并扩展实际的@Repository

@Repository
public interface BookSqlMappingRepository extends JpaRepository<Book, Integer>, BookCustomRepository {}

最后创建实现类:

@Repository
public class BookSqlMappingRepositoryImpl implements BookCustomRepository {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public List<BookDto> fetchBooks() {
        return entityManager.createNativeQuery("SELECT b.id, b.title, b.author FROM Book b", "BookMappingResultSet")
          .getResultList();
    }
}

fetchBooks()方法中,通过EntityManagercreateNativeQuery()方法创建原生查询。

为该仓库添加测试:

@Test
public void whenUseSqlMapping_thenFetchOnlyColumnResults() {
    String title = "Title Sql Mapping";
    String author = "Author Sql Mapping";

    Book book = new Book();
    book.setTitle(title);
    book.setAuthor(author);
    bookSqlMappingRepository.save(book);

    List<BookDto> result = bookSqlMappingRepository.fetchBooks();

    assertEquals(1, result.size());
    assertEquals(title, result.get(0).title());
    assertEquals(author, result.get(0).author());
}

@SqlResultSetMapping是更复杂的解决方案。但如果仓库中有多个使用EntityManager和原生/命名查询的操作,可能值得采用。

7. 使用ObjectTuple

**可通过原生查询并限制字段的方式使用ObjectTuple**。虽然这些方法可读性较差(无法直接访问类属性),但仍是有效选项。

7.1. Object数组

无需添加传输对象,直接使用Object类:

@Repository
public interface BookObjectsRepository extends JpaRepository<Book, Integer> {
    @Query("SELECT b.id, b.title, b.author FROM Book b")
    List<Object[]> fetchBooks();
}

测试中访问对象值的方式:

@Test
public void whenUseObjectArray_thenFetchOnlyQueryFields() {
    String title = "Title Object";
    String author = "Author Object";

    Book book = new Book();
    book.setTitle(title);
    book.setAuthor(author);
    bookObjectsRepository.save(book);

    List<Object[]> result = bookObjectsRepository.fetchBooks();

    assertEquals(1, result.size());
    assertEquals(3, result.get(0).length);
    assertEquals(title, result.get(0)[1].toString());
    assertEquals(author, result.get(0)[2].toString());
}

可见这不是动态方案,因为必须知道数组中列的位置。

7.2. Tuple

也可使用Tuple类。它是Object数组的包装器,优势在于可通过别名而非位置访问属性(如前例所示)。创建BookTupleRepository

@Repository
public interface BookTupleRepository extends JpaRepository<Book, Integer> {
    @Query(value = "SELECT b.id, b.title, b.author FROM Book b", nativeQuery = true)
    List<Tuple> fetchBooks();
}

测试中访问Tuple值的方式:

@Test
public void whenUseTuple_thenFetchOnlyQueryFields() {
    String title = "Title Tuple";
    String author = "Author Tuple";

    Book book = new Book();
    book.setTitle(title);
    book.setAuthor(author);
    bookTupleRepository.save(book);

    List<Tuple> result = bookTupleRepository.fetchBooks();

    assertEquals(1, result.size());
    assertEquals(3, result.get(0).toArray().length);
    assertEquals(title, result.get(0).get("title"));
    assertEquals(author, result.get(0).get("author"));
}

这仍不是动态方案,但可通过别名或列名访问列值。

8. 总结

本文介绍了使用Spring Data限制数据库结果集列数的方法。我们学习了如何使用投影、DTO和@SqlResultSetMapping得到相似结果,还探讨了如何使用ObjectTuple按数组位置访问通用结果集。

✅ 优先推荐方案:

  • 投影:简洁高效,适合简单字段选择
  • DTO:灵活可控,适合复杂转换逻辑

⚠️ 谨慎使用的方案:

  • @SqlResultSetMapping:配置复杂,适合多查询复用场景
  • Object/Tuple:可读性差,仅建议在特殊场景使用

选择方案时需权衡开发效率与运行时性能,避免过度优化导致代码维护困难。


原始标题:How to Make a Field Optional in JPA Entity? | Baeldung