2018年最初のエントリーは・・・@sndr さんの「Spring Data JDBC Preview」を見て「へ~」と思ったSpring Data JDBCを試した際のメモです。まだ単純なCRUDレベルのサポートだけのようですが、Spring Data JDBCが正式にリリースされてSpring Data RESTのサポート対象になることを(非常に)期待しています!
↓
2018/9/21に正式リリースされたので、内容を1.0.0.RELEASEベースに修正しました!
検証バージョン
- Spring Data JDBC 1.0.0.RELEASE
- Spring Boot 2.0.5.RELEASE
- MyBatis Spring Boot Starter 1.3.2 (MyBatis 3.4.6 + MyBatis Spring 1.3.2)
- H2 Database 1.4.197
NOTE: 更新履歴
2018-02-06 :
- DATAJDBC-161対応に伴うインタフェース変更に対応
- Spring Boot 2.0.0.RC1で再検証
2018-02-08 :
@Query
メソッドの利用方法を追加(DATAJDBC-172に対応)2018-03-08 :
- DATAJDBC-175対応に伴い
@Query
メソッドの返り値としてシンプル型がサポートされたことを追記- Spring Boot 2.0.0.RELEASEで再検証
2018-03-09 :
- 関連オブジェクトの扱いに間する記載を追加
- 上記に関連し、Spring Boot 2.0.0.RELEASE上でSpring Data JDBCを利用する場合はspring-data-releasetrainのLovelace-BUILD-SNAPSHOTのimportが必要になる旨を追記
2018-03-10 :
- 更新系クエリ(
@Modifying
)の利用方法を追加(DATAJDBC-182に対応)2018-03-23 :
- DATAJDBC-189対応に伴い
DefaultNamingStrategy
を利用しないように修正(デフォルト実装はNamingStrategy.INSTANCE
として定義される)- MyBatis Spring Boot Starter 1.3.2へ更新
2018-03-31 :
- DATAJDBC-155対応に伴い
DefaultDataAccessStrategy
生成時にNamedParameterJdbcOperations
を指定しないように修正- DATAJDBC-184対応に伴いスネークケース(アンダースコア区切り)をサポートする
NamingStrategy
が追加された旨を追記2018-04-03 :
- DATAJDBC-178対応に伴い、
MyBatisDataAccessStrategy
に任意のNamespaceStrategy
インスタンスを設定することで、ネームスペースの命名規約を変更できるようになった旨を追記2018-05-18 :
- アノテーションベースのAuditing機能のサポートに関する記載を追加(DATAJDBC-204に対応)
- Spring Boot 2.0.2.RELEASEへ更新し再検証
2018-05-19 :
- パッケージ構成の変更を反映(DATAJDBC-138に対応)
2018-06-28 :
- パッケージ構成とクラス名の変更を反映(DATAJDBC-226に対応)
- DATAJDBC-207に対応に伴い
NamingStrategy
のデフォルト実装がスネークケースに変更された旨を追記- Spring Boot 2.0.3.RELEASEへ更新し再検証
2018-07-03 :
- spring-data-releasetrain Lovelace-BUILD-SNAPSHOTの明示的なimportを削除し、プロパティに
<spring-data-releasetrain.version>Lovelace-BUILD-SNAPSHOT</spring-data-releasetrain.version>
を追加するように修正2018-07-20 :
- カスタムコンバータの適用方法の変更を反映(DATAJDBC-235に対応)
- deleteAll時にMyBatisでエラーがでる問題を修正。Entityを削除するSQL IDに変更があった模様!?
2018-07-28 :
JdbcConfiguration
の適用方法の変更を反映(DATAJDBC-243に対応)2018-09-22 :
- Spring Data JDBCの検証バージョンを1.0.0.RELEASEに修正
- Spring Bootの検証バージョンを2.0.5.RELEASEに修正
- spring-boot-starter-data-jdbcについて記載
2018-09-23 :
- JdbcConfigurationの指定・拡張方法に関する説明を修正(DATAJDBC-267の影響に伴う修正)
デモプロジェクト
本エントリーで記載したソースは、以下のリポジトリで公開しています。(Spring JDBCとMyBatisを混在させて検証を行っている関係で、エントリー内の記載と異なる箇所があります)
開発プロジェクトの作成
まず、SPRING INITIALIZRにて、Dependenciesに「H2」「JDBC」「MyBatis(MyBatisを利用する場合のみ)」を選択してプロジェクトを作成する。(本エントリではMavenプロジェクト前提での説明になります)
次に、SPRING INITIALIZRで作成したプロジェクトに「spring-data-jdbc」を追加する。
Spring Boot 2.0系でSpring Data JDBCを利用する場合は、以下のようにSpring Boot提供のプロパティにspring-data-releasetrainのLovelace-RELEASEバージョンを指定する必要がある。
<properties>
<spring-data-releasetrain.version>Lovelace-RELEASE</spring-data-releasetrain.version>
</properties>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jdbc</artifactId>
</dependency>
DBのセットアップ
テーブルを作るためのDDLを用意する。
CREATE TABLE IF NOT EXISTS todo (
id IDENTITY
,title TEXT NOT NULL
,details TEXT
,finished BOOLEAN NOT NULL
);
ドメインオブジェクトの作成
TODOテーブルのレコードを表現するTodoオブジェクトを作る。キー値を保持するプロパティに@Id
を付与する。
package com.example.demo.domain;
import org.springframework.data.annotation.Id;
public class Todo {
@Id
private int id;
private String title;
private String details;
private boolean finished;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDetails() {
return details;
}
public void setDetails(String details) {
this.details = details;
}
public boolean isFinished() {
return finished;
}
public void setFinished(boolean finished) {
this.finished = finished;
}
}
Repositoryの作成
ドメインオブジェクトを操作するためのRepositoryインタフェースを作成する。Spring Dataが提供するCroudRepository
を継承するのがポイント。
package com.example.demo.repository;
import org.springframework.data.repository.CrudRepository;
import com.example.demo.domain.Todo;
public interface TodoRepository extends CrudRepository<Todo, Integer> {
}
こうすることで・・・CrudRepository
に定義されている以下のメソッドを使用してTodoオブジェクトを操作することができる。
package org.springframework.data.repository;
import java.util.Optional;
@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
<S extends T> Iterable<S> saveAll(Iterable<S> entities);
Optional<T> findById(ID id);
boolean existsById(ID id);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable<ID> ids);
long count();
void deleteById(ID id);
void delete(T entity);
void deleteAll(Iterable<? extends T> entities);
void deleteAll();
}
SQLの実行方法
Spring Data JDBCは、SQLを実行する方法を抽象化するためのインタフェースとしてDataAccessStrategy
を用意しており、現時点では「Spring JDBC(NamedParameterJdbcOperations
)実装」と「MyBatis実装」が内蔵されている。
Spring JDBC実装の利用
Spring JDBC実装を利用すると、CrudRepository
に定義されているメソッドを呼び出した時に実行するSQLは自動生成される(=CRUD操作についてはSQLを書く必要がない)。
Bean定義例
@EnableJdbcRepositories
と@Import(JdbcConfiguration.class)
を付与したコンフィギュレーションクラスを作成する。ただし・・・JdbcConfiguration
に定義があるBeanを変更したい場合は、JdbcConfiguration
を継承したコンフィギュレーションクラスを作成してDIコンテナ登録する必要がある。例えば、デフォルトでサポートしていない型変換が必要になる場合は、jdbcCustomConversions
メソッドをオーバライドして任意のConverter
などを指定したJdbcCustomConversions
を返却するようにすればよい。本エントリーでは、H2 DatabaseのTEXT型(Clob
)をString
に変換するためのConverter
を追加している。ちなみに・・・TEXTの代わりにVARCHARを使えばjdbcCustomConversions
メソッドのオーバーライドは不要である。
@EnableJdbcRepositories
@Configuration
public class SpringDataJdbcConfig extends JdbcConfiguration {
@Override
protected JdbcCustomConversions jdbcCustomConversions() {
return new JdbcCustomConversions(Collections.singletonList(new Converter<Clob, String>() {
@Override
public String convert(Clob clob) {
try {
return clob == null ? null : clob.getSubString(1L, (int) clob.length());
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}));
}
}
Note:
カラム名とプロパティ名のネーミング戦略を決めるためのインタフェースとして
NamingStrategy
が用意されている。デフォルトではNamingStrategy.INSTANCE
が利用されるが、NamingStrategy
のBeanを定義することでデフォルトの動作を変更することができる。なお、DATAJDBC-184対応によりスネークケース(アンダースコア区切り)をサポートするNamingStrategy
が追加された(利用する場合はBean定義が必要)。
なお、デフォルトの動作はスネークケース(アンダースコア区切り)として扱われる(DATAJDBC-206対応でデフォルト動作が変更された)。
MyBatis実装の利用
MyBatis実装を利用する場合は、CrudRepository
に定義されているメソッドを呼び出した時に実行するSQLをMyBatis側に定義しておく必要がある。(=CRUD操作についてもSQLを書く必要がある)。
Bean定義例
@EnableJdbcRepositories
と@Import(JdbcConfiguration.class)
を付与したコンフィギュレーションクラスを作成し、DataAccessStrategy
としてMyBatisDataAccessStrategy
(MyBatis実装)のBeanを定義する。
@EnableJdbcRepositories
@Import(JdbcConfiguration.class)
@Configuration
public class SpringDataJdbcConfig {
@Bean
DataAccessStrategy dataAccessStrategy(SqlSession sqlSession) {
return new MyBatisDataAccessStrategy(sqlSession);
}
}
NOTE:
2018-02-06: DATAJDBC-161対応に伴い、
MyBatisDataAccessStrategy
のコンストラクタ引数に渡すオブジェクトをSqlSessionFactory
からSqlSession
(実体はSqlSessionTemplate
)に変更。
MyBatisの設定例
Mapper XMLファイルのロケーションやタイプエイリアスの設定を行う。
mybatis.mapper-locations=classpath:/com/example/demo/mapper/*Mapper.xml
mybatis.type-aliases-package=com.example.demo.domain
SQLの定義例
CrudRepository
のメソッドに対応するSQL定義を行う。Spring Data JDBC経由でMyBatisを利用する場合は、いくつかの特殊ルールを意識してSQL定義を行う必要がある。
- 対応するSQL定義のネームスペースは「ドメインオブジェクトのFQCN + "Mapper"」にする
- パラメータは
MyBatisContext
というクラスにラップされて渡される(MyBatisContext
の抜粋は別途掲載) -
CrudRepository
のメソッドとSQL定義の対応が1対1というわけではない(MyBatisのMapperインタフェースの対応ルールと異なる)。具体的な対応は、Spring Data JDBCのREADMEを参照。
NOTE:
2018-04-03: DATAJDBC-178対応に伴い、
MyBatisDataAccessStrategy
に任意のNamespaceStrategy
インスタンスを設定することで、ネームスペースの命名規約を変更することができる。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.TodoMapper">
<!-- statements for CrudRepository method -->
<insert id="insert" useGeneratedKeys="true" keyProperty="instance.id">
INSERT INTO todo
(title, details, finished)
VALUES
(#{instance.title}, #{instance.details}, #{instance.finished})
</insert>
<update id="update">
UPDATE todo SET
title = #{instance.title}, details = #{instance.details}, finished = #{instance.finished}
WHERE
id = #{instance.id}
</update>
<delete id="delete">
DELETE FROM todo WHERE id = #{id}
</delete>
<delete id="deleteAll">
DELETE FROM todo
</delete>
<select id="existsById" resultType="_boolean">
SELECT count(*) FROM todo WHERE id = #{id}
</select>
<select id="findById" resultType="Todo">
SELECT
id, title, details, finished
FROM
todo
WHERE
id = #{id}
</select>
<select id="findAll" resultType="Todo">
SELECT
id, title, details, finished
FROM
todo
ORDER BY
id
</select>
<select id="findAllById" resultType="Todo">
SELECT
id, title, details, finished
FROM
todo
<where>
<foreach collection="id" item="idValue" open="id in("
separator="," close=")">
#{idValue}
</foreach>
</where>
ORDER BY
id
</select>
<select id="count" resultType="_long">
SELECT count(*) FROM todo
</select>
</mapper>
package org.springframework.data.jdbc.mybatis;
import java.util.Map;
public class MyBatisContext {
private final Object id;
private final Object instance;
private final Class domainType;
private final Map<String, Object> additonalValues;
public MyBatisContext(Object id, Object instance, Class domainType, Map<String, Object> additonalValues) {
this.id = id;
this.instance = instance;
this.domainType = domainType;
this.additonalValues = additonalValues;
}
public Object getId() {
return id;
}
public Object getInstance() {
return instance;
}
public Class getDomainType() {
return domainType;
}
public Object get(String key) {
return additonalValues.get(key);
}
}
実装の併用
本エントリーでは説明しません+検証もしていませんが、CascadingDataAccessStrategy
を使用して複数の実装(例、Spring JDBCとMyBatis)を併用することもできるみたいです。
Repositoryの利用
Spring Data JDBCのRepositoryは、他のSpring Dataプロジェクトと同様にインジェクションして使用する。
@Autowired
private TodoRepository todoRepository;
@Test
public void insertAndFineById() {
Todo newTodo = new Todo();
newTodo.setTitle("飲み会");
newTodo.setDetails("銀座 19:00");
todoRepository.save(newTodo);
Optional<Todo> todo = todoRepository.findById(newTodo.getId());
Assertions.assertThat(todo.isPresent()).isTrue();
Assertions.assertThat(todo.get().getId()).isEqualTo(newTodo.getId());
Assertions.assertThat(todo.get().getTitle()).isEqualTo(newTodo.getTitle());
Assertions.assertThat(todo.get().getDetails()).isEqualTo(newTodo.getDetails());
Assertions.assertThat(todo.get().isFinished()).isFalse();
}
@Query
メソッドの追加
Repositoryに@Query
を付与したメソッドを追加することで、任意のクエリを(Spring JDBCの機能を使用して)実行することができる。
WARNING:
現時点の実装では、更新系のSQLを実行することはできない(サポートする予定はある模様)。NOTE:
2018-03-10: DATAJDBC-182で更新系のSQLの実行もサポートされた。
package com.example.demo.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import com.example.demo.domain.Todo;
public interface TodoRepository extends CrudRepository<Todo, Integer> {
@Query("SELECT * FROM todo WHERE id = :id")
Optional<Todo> findOptionalById(@Param("id") Integer id);
@Query("SELECT * FROM todo WHERE id = :id")
Todo findEntityById(@Param("id") Integer id);
@Query("SELECT * FROM todo ORDER BY id")
Stream<Todo> findAllStream();
@Query("SELECT * FROM todo ORDER BY id")
List<Todo> findAllList();
@Query("SELECT count(*) FROM todo WHERE finished = :finished")
long countByFinished(@Param("finished") Boolean finished);
@Query("SELECT count(*) FROM todo WHERE finished = :finished")
boolean existsByFinished(@Param("finished") Boolean finished);
@Query("SELECT current_timestamp()")
LocalDateTime currentDateTime();
@Modifying
@Query("UPDATE todo SET finished = :finished WHERE id = :id")
boolean updateFinishedById(@Param("id") Integer id, @Param("finished") boolean finished);
}
NOTE:
@Param
はJavaコンパイラの-parameters
オプションを指定することで省略することができる。
現時点で返り値の型としてサポートされているのは、
-
T
(ドメインクラス) java.util.Optional<T>
-
java.lang.Iterable<T>
に割り当て可能な型(java.util.List<T>
など) java.util.stream.Stream<T>
- シンプル型(プリミティブ型、プリミティブラッパー型など)
であり、org.springframework.data.domain.Page<T>
やorg.springframework.data.domain.Slice<T>
はサポートされていない(これらの型を返り値として扱いたい場合は、後述の「カスタム操作の追加」が必要になる)。
なお、更新系メソッドの返り値としてサポートされているのは、
-
int
(Integer
) -
boolean
(Boolean
) void
である。
WARNING:
現時点の実装では、数値(int
やlong
など)や真偽値といったドメインクラス以外の型を返り値に指定することができない(つまり・・・@Query
メソッドデレコード数を取得するSQLやレコードの存在チェックを行うようなSQLを指定することができない)。後述の「カスタム操作の追加」で紹介する方法で対応はできるけど・・・なんとなく考慮漏れっぽい気がするのでIssueあげてみようかな~。⇒ DATAJDBC-175
↓
2018-03-08 : 数値(int
やlong
など)や真偽値といったドメインクラス以外の型(いわゆるシンプル型)を返り値として返却できるようになりました!! 内部の作り的には・・・SingleColumnRowMapper
+Spring Data JDBCに適用したConversionService
と連携して型の解決が行なわれます。ちなみに・・・この対応に伴いSpring Framework 5.0.4.RELEASE以上が必要になります。
カスタム操作の追加
Spring Dataには「カスタム操作(カスタムメソッド)を追加する仕組み」があり、この仕組みはSpring Data JDBCでも利用することができる。
カスタムインタフェースの作成
カスタム操作(カスタムメソッド)を定義するためのインタフェースを定義する。
package com.example.demo.repository;
import com.example.demo.domain.Todo;
public interface CustomizedTodoRepository {
Iterable<Todo> findAllByFinished(boolean finished);
}
作成したインタフェースをTodoRepository
で継承する。
package com.example.demo.repository;
import org.springframework.data.repository.CrudRepository;
import com.example.demo.domain.Todo;
public interface TodoRepository extends CrudRepository<Todo, Integer>, CustomizedTodoRepository {
}
Spring JDBC実装の作成
Spring JDBCを利用する場合は、以下のような実装クラスを作成する。
package com.example.demo.repository;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
import com.example.demo.domain.Todo;
public class CustomizedTodoRepositoryImpl implements CustomizedTodoRepository {
private static final RowMapper<Todo> ROW_MAPPER = new BeanPropertyRowMapper<>(Todo.class);
private final NamedParameterJdbcOperations namedParameterJdbcOperations;
public CustomizedTodoRepositorySpringJdbcImpl(NamedParameterJdbcOperations namedParameterJdbcOperations) {
this.namedParameterJdbcOperations = namedParameterJdbcOperations;
}
public Iterable<Todo> findAllByFinished(boolean finished) {
return this.namedParameterJdbcOperations.query(
"SELECT id, title, details, finished FROM todo WHERE finished = :finished ORDER BY id",
new MapSqlParameterSource("finished", finished), ROW_MAPPER);
}
}
MyBatis実装の作成
MyBatisを利用する場合は、以下のような実装クラスを作成する。
package com.example.demo.repository;
import org.apache.ibatis.session.SqlSession;
import com.example.demo.domain.Todo;
public class CustomizedTodoRepositoryImpl implements CustomizedTodoRepository {
private final String NAMESPACE = Todo.class.getName() + "Mapper";
private final SqlSession sqlSession;
public CustomizedTodoRepositoryMyBatisImpl(SqlSession sqlSession) {
this.sqlSession = sqlSession;
}
public Iterable<Todo> findAllByFinished(boolean finished) {
return this.sqlSession.selectList(NAMESPACE + ".findAllByFinished", finished);
}
}
SQL定義も追加する。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.TodoMapper">
<!-- ... -->
<!-- statements for custom repository method -->
<select id="findAllByFinished" resultType="Todo">
SELECT
id, title, details, finished
FROM
todo
WHERE
finished = #{finished}
ORDER BY
id
</select>
</mapper>
関連オブジェクト(1:1 or 1:N)の永続化操作
Spring Data JDBCは、1:1または1:Nの関係となる関連オブジェクトに対する永続化操作をサポートしている。ただし・・・サポート状況はSpring JDBC実装とMyBatis実装で異なる。ざっと見た感じだと・・・更新系操作のサポート状況は同じ。ただし・・・MyBatis使用時の参照系の操作については、MyBatis側での実装(テーブル結合+associationやcollectionを使用した1:1/1:Nマッピング)が必要になる。
DBのセットアップ
関連オブジェクトを永続化するためのテーブルを作成する。
CREATE TABLE IF NOT EXISTS activity (
id IDENTITY
,todo INTEGER NOT NULL -- ドメインオブジェクトのIDを格納するカラム
,todo_key INTEGER NOT NULL -- ドメインオブジェクト内での関連オブジェクトの識別キー(兼ソートキー)を格納するカラム
,content TEXT NOT NULL
,at TIMESTAMP NOT NULL
);
Spring Data JDBCのデフォルト実装では、「ドメインオブジェクトのIDを格納するカラム」のカラム名は「ドメインオブジェクトのクラス名」、「ドメインオブジェクト内での関連オブジェクトの識別キーを格納するカラム」のカラム名は「ドメインオブジェクトのIDを格納するカラム + "_key"」になる。
関連オブジェクトの作成
TODOのアクティビティを表現するドメインオブジェクトを作成し、Todoオブジェクトに関連付ける。
package com.example.demo.domain;
import org.springframework.data.annotation.Id;
import java.time.LocalDateTime;
public class Activity {
@Id
private int id;
private String content;
private LocalDateTime at;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDateTime getAt() {
return at;
}
public void setAt(LocalDateTime at) {
this.at = at;
}
}
public class Todo {
// ...
private List<Activity> activities;
// ...
public List<Activity> getActivities() {
return activities;
}
public void setActivities(List<Activity> activities) {
this.activities = activities;
}
}
CRUD操作の実行例
ここでは、1:Nの関係をもつ関連オブジェクトを保持するドメインオブジェクトを、CrudRepository
に定義されているメソッドを利用して操作する。
@Test
public void oneToMany() {
// Insert
Todo newTodo = new Todo();
newTodo.setTitle("飲み会");
newTodo.setDetails("銀座 19:00");
Activity activity1 = new Activity();
activity1.setContent("Created");
activity1.setAt(LocalDateTime.now());
Activity activity2 = new Activity();
activity2.setContent("Started");
activity2.setAt(LocalDateTime.now());
newTodo.setActivities(Arrays.asList(activity1, activity2));
todoRepository.save(newTodo);
// Assert for inserting
Optional<Todo> loadedTodo = todoRepository.findById(newTodo.getId());
Assertions.assertThat(loadedTodo.isPresent()).isTrue();
loadedTodo.ifPresent(todo -> {
Assertions.assertThat(todo.getId()).isEqualTo(newTodo.getId());
Assertions.assertThat(todo.getTitle()).isEqualTo(newTodo.getTitle());
Assertions.assertThat(todo.getDetails()).isEqualTo(newTodo.getDetails());
Assertions.assertThat(todo.isFinished()).isFalse();
Assertions.assertThat(todo.getActivities()).hasSize(2);
Assertions.assertThat(todo.getActivities().get(0).getContent()).isEqualTo(activity1.getContent());
Assertions.assertThat(todo.getActivities().get(1).getContent()).isEqualTo(activity2.getContent());
});
// Update
Activity activity3 = new Activity();
activity3.setContent("Changed Title");
activity3.setAt(LocalDateTime.now());
loadedTodo.ifPresent(todo -> {
todo.setTitle("[Change] " + todo.getTitle());
todo.getActivities().add(activity3);
});
todoRepository.save(loadedTodo.get());
// Assert for updating
loadedTodo = todoRepository.findById(newTodo.getId());
Assertions.assertThat(loadedTodo.isPresent()).isTrue();
loadedTodo.ifPresent(todo -> {
Assertions.assertThat(todo.getTitle()).isEqualTo("[Change] " + newTodo.getTitle());
Assertions.assertThat(todo.getActivities()).hasSize(3);
Assertions.assertThat(todo.getActivities().get(0).getContent()).isEqualTo(activity1.getContent());
Assertions.assertThat(todo.getActivities().get(1).getContent()).isEqualTo(activity2.getContent());
Assertions.assertThat(todo.getActivities().get(2).getContent()).isEqualTo(activity3.getContent());
});
// Delete
todoRepository.deleteById(newTodo.getId());
// Assert for deleting
Assertions.assertThat(todoRepository.findById(newTodo.getId())).isNotPresent();
}
Spring JDBC実装の利用
Spring JDBC実装を使う場合は、特に何も行う必要はない。単純にCrudRepositoryのメソッドを呼び出せば、Spring Data JDBCがよろしくSQLを生成して実行してくれる。
MyBatis実装の利用
MyBatis実装を使う場合は、Mapper XMLファイルにSQLの定義が必要になる。
まず・・・関連オブジェクトをINSERTするためのSQLを定義する。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.ActivityMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="instance.id">
INSERT INTO activity
(todo, todo_key, content, at)
VALUES
(#{additonalValues.Todo}, #{additonalValues.Todo_key}, #{instance.content}, #{instance.at})
</insert>
</mapper>
ここでポイントになるのは・・・「ドメインオブジェクトのID」と「ドメインオブジェクト内での関連オブジェクトの識別キー(兼ソートキー)」がMap
型のadditonalValues
プロパティに格納されるという点で、値が格納されるキー名はカラム名と同じルール。
次に・・・関連オブジェクトをDELETEするためのSQLを定義する。このSQLは、ドメインオブジェクトを更新する際にも呼び出される。つまり・・・1:Nの関係をもつ関連オブジェクトは、いったん全てDELETEしてから再度INSERTする仕組みになっている。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.TodoMapper">
<!-- ... -->
<delete id="delete-activities">
DELETE FROM activity WHERE todo = #{id}
</delete>
<delete id="deleteAll-activities">
DELETE FROM activity WHERE todo = #{id}
</delete>
<!-- ... -->
</mapper>
さいごに・・・ドメインオブジェクトを参照するSQLの中で関連オブジェクトも取得するように修正する。具体的には・・・関連オブジェクトの情報を保持するテーブルを結合し、MyBatisのResultMap機能を利用して関連オブジェクトをドメインオブジェクトへマッピングする。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.ActivityMapper">
<!-- ... -->
<select id="findById" resultMap="todoMap">
SELECT
t.id, t.title, t.details, t.finished
, a.id as activity_id, a.content as activity_content, a.at as activity_at
FROM
todo t
LEFT OUTER JOIN activity a ON a.todo = t.id
WHERE
t.id = #{id}
ORDER BY
a.todo_key
</select>
<resultMap id="todoMap" type="Todo">
<id column="id" property="id"/>
<result column="title" property="title"/>
<result column="details" property="details"/>
<result column="finished" property="finished"/>
<collection property="activities" columnPrefix="activity_" ofType="Activity">
<id column="id" property="id"/>
<result column="content" property="content"/>
<result column="at" property="at"/>
</collection>
</resultMap>
<!-- ... -->
</mapper>
関連付け用カラムのカラム名変更
Spring Data JDBCのデフォルト実装で生成されるカラム名が気に入らない場合は・・・NamingStrategy
の実装クラスをBean定義することで変更することができる。ここでは・・・Todo
をtodo_id
へ、Todo_key
をsort_order
というカラム名に変更する方法を紹介する。
まず・・・テーブルのカラム名を変更する。
CREATE TABLE IF NOT EXISTS activity (
id IDENTITY
,todo_id INTEGER NOT NULL -- カラム名を変更
,sort_order INTEGER NOT NULL -- カラム名を変更
,content TEXT NOT NULL
,at TIMESTAMP NOT NULL
);
つぎに・・・NamingStrategy
の実装クラスをBean定義する。
@Bean
NamingStrategy namingStrategy() {
return new NamingStrategy(){
@Override
public String getReverseColumnName(RelationalPersistentProperty property) {
return NamingStrategy.super.getReverseColumnName(property).toLowerCase() + "_id";
}
@Override
public String getKeyColumn(RelationalPersistentProperty property) {
return "sort_order";
}
};
}
MyBatis実装を使う場合は、SQLの修正も必要となる。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.ActivityMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="instance.id">
INSERT INTO activity
(todo_id, sort_order, content, at)
VALUES
(#{additonalValues.todo_id}, #{additonalValues.sort_order}, #{instance.content}, #{instance.at})
</insert>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.ActivityMapper">
<!-- ... -->
<delete id="delete-activities">
DELETE FROM activity WHERE todo_id = #{id}
</delete>
<delete id="deleteAll-activities">
DELETE FROM activity WHERE todo_id = #{id}
</delete>
<!-- ... -->
<select id="findById" resultMap="todoMap">
SELECT
t.id, t.title, t.details, t.finished
, a.id as activity_id, a.content as activity_content, a.at as activity_at
FROM
todo t
LEFT OUTER JOIN activity a ON a.todo_id = t.id
WHERE
t.id = #{id}
ORDER BY
a.sort_order
</select>
<!-- ... -->
</mapper>
監査用カラムへの値設定(Auditing機能)
Spring Dataには、「いつ・誰が・データを作成・更新(最終更新)したかを保持するカラム(監査用カラム)に値を設定する仕組みがあり、Spring Data JDBCでもこの機能を利用することができる。
IMPORTANT:
Spring Data JDBC 1.0.0.RELEASE(Lovelace)の時点では、ネストしたオブジェクト(1:1, 1:N)に対するAuditing機能がサポートできていない部分がある。これは、Spring Data JDBC側の問題ではなく、Spring Data Commons側の問題だと思われる。
- [DATACMNS-1297]
(https://jira.spring.io/projects/DATACMNS/issues/DATACMNS-1297) : 1:Nの関係をもつN側のクラスに監査用のプロパティが存在する場合に常にエラーが発生する(データが0件でもエラーとなる)- [DATACMNS-1296]
(https://jira.spring.io/projects/DATACMNS/issues/DATACMNS-1296) :
ネスト側のクラスに監査用のプロパティが存在する場合に、オブジェクト自体がnull
の場合エラーが発生する。
Auditing機能の有効化
Auditing機能を利用する場合は、コンフィギュレーションクラスに @org.springframework.data.jdbc.repository.config.EnableJdbcAuditing
を付与する。
@EnableJdbcAuditing // 追加
@Import(JdbcConfiguration.class)
@EnableJdbcRepositories
@Configuration
public class SpringDataJdbcConfig {
// ...
}
「だれ」が作成・更新したかを記録する場合は、 org.springframework.data.domain.AuditorAware
インタフェースを実装したクラス
package com.example.demo;
import org.springframework.data.domain.AuditorAware;
import java.util.Optional;
public class MyAuditorAware implements AuditorAware<String> {
static ThreadLocal<String> currentUser = ThreadLocal.withInitial(() -> "default");
public Optional<String> getCurrentAuditor() {
return Optional.ofNullable(currentUser.get());
}
}
NOTE:
ここでは、簡易的にスレッドローカルな変数に設定されたユーザ名をデータの作成者・最終更新者として返却するような実装にしていますが、実際のアプリケーションを開発する際には、Spring Securityなどの認証機能で管理しているログインユーザ名などを返却するのが一般的である。
を作成し、アプリケーションコンテキストに登録する。
@EnableJdbcAuditing
@Import(JdbcConfiguration.class)
@EnableJdbcRepositories
@Configuration
public class SpringDataJdbcConfig {
// ...
@Bean
AuditorAware<String> auditorAware() {
return new MyAuditorAware();
}
// ...
}
また、「いつ」の時間の取得方法をデフォルト実装(CurrentDateTimeProvider
= LocalDateTime.now()
)から変更したい場合は、 org.springframework.data.auditing.DateTimeProvider
インタフェースを実装したオブジェクトをアプリケーションコンテキストに登録し、@EnableJdbcAuditing
の dateTimeProviderRef
属性に登録したオブジェクトのBeanIDを指定する。
@EnableJdbcAuditing(dateTimeProviderRef = "dateTimeProvider")
@Import(JdbcConfiguration.class)
@EnableJdbcRepositories
@Configuration
public class SpringDataJdbcConfig {
// ...
@Bean
DateTimeProvider dateTimeProvider(ObjectProvider<Clock> clockObjectProvider) {
return () -> Optional.of(LocalDateTime.now(clockObjectProvider.getIfAvailable(Clock::systemDefaultZone)));
}
// ...
}
Auditing用カラムの追加
Auditing機能を有効化したら、まずテーブルに監査カラムを追加する。
CREATE TABLE IF NOT EXISTS todo (
id IDENTITY
,title TEXT NOT NULL
,details TEXT
,finished BOOLEAN NOT NULL
,created_at TIMESTAMP
,created_by VARCHAR(64)
,last_updated_at TIMESTAMP
,last_updated_by VARCHAR(64)
);
アノテーションベースのAuditing機能の利用
Spring Data JDBC 1.0.0.RELEASE(Lovelace)の時点でサポートされているのは、アノテーションベースのAuditing機能のみ。
アノテーションベースのAuditing機能を利用する場合には、監査カラムの値をプロパティを追加し、以下のアノテーションを対象カラムに付与する。
@CreatedDate
@CreatedBy
@LastModifiedDate
@LastModifiedBy
WARNING:
Spring Data JDBC 1.0 M3(Lovelace)の時点では、-> 2018-05-18 対応されました@Id
を付与したプロパティの型がプリミティブだと正しく動作しない。(DATAJDBC-216)
package com.example.demo.domain;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.relational.core.mapping.Column;
import java.time.LocalDateTime;
import java.util.List;
public class Todo {
@Id
private int id;
private String title;
private String details;
private boolean finished;
// 作成日時
@CreatedDate
@Column("created_at")
private LocalDateTime createdAt;
// 作成者
@CreatedBy
@Column("created_by")
private String createdBy;
// 最終更新日時
@LastModifiedDate
@Column("last_updated_at")
private LocalDateTime lastUpdatedAt;
// 最終更新者
@LastModifiedBy
@Column("last_updated_by")
private String lastUpdatedBy;
private List<Activity> activities;
// setters/getters
}
NOTE:
Auditing機能と関係ないが、
@Column
はSpring Data JDBC 1.0 M3(Lovelace)リリース後にサポートされたアノテーション(DATAJDBC-106)。ちなみに・・スネークケースのカラムとキャメルケースのプロパティ名のマッピングのような一定のルールがある場合は、(→DATAJDBC-206対応に伴いスネークケースのカラム名とキャメルケースのプロパティ名のマッピングがデフォルト動作になった)NamingStrategy
を利用して名称の違いを吸収することもできる。
インタフェースベースのAuditing機能
インタフェースベースのAuditing機能は、Spring Data JDBC 1.0.0.RELEASE(Lovelace)の時点ではサポートされていない。これは、Spring Data JDBCが Optional
なプロパティをまだサポートしていないためである(DATAJDBC-205)。
Spring Boot連携
一応・・・開発者の方の個人リポジトリにspring-data-jdbc-boot-starterがあるようですが、現時点ではどこにもデプロイされていないのでローカルリポジトリにインストールして使う必要があります。(とりあえず私は今回は使いませんでした)
↓
Spring Boot 2.1(2.1.0.M4)からAutoConfigureおよびStarterが提供されます!
NOTE:
「spring-boot-starter-data-jdbc(2.1.0.BUILD-SNAPSHOT)を試す」を書いてみました。
まとめ
まだまだ開発途中で成長するライブラリだと思うので、動向を見守っていきたいと思います。Repositoryインタフェースにカスタムメソッド定義できてアノテーションでSQLを指定できるようになるとかなり実用的になりそうな気がします(READMEを見る限りだとサポートする計画はありそう⇒対応されました!!)。
そして、Spring Data RESTとの連携がサポートされれば最高だ~。
↓
ついに1.0.0が正式リリースされました!!発展途上のライブラリであることには変わりはありませんが、このエントリーを書いた時(2018/01/08)と比較するとかなり成長したと思います。Spring Data RESTのリファレンス(Spring Data REST 3.1.0.RELEASE)を見る限りだと・・・正式なサポートはまだみたいです(Issueあげてみようかな・・・)。