Java
spring
MyBatis
spring-boot
spring-data

Spring Data JDBC 1.0.0.BUILD-SNAPSHOT(-> 1.0.0.RELEASE)を試してみた

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バージョンを指定する必要がある。

pom.xml
<properties>
    <spring-data-releasetrain.version>Lovelace-RELEASE</spring-data-releasetrain.version>
</properties>
pom.xml
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-jdbc</artifactId>
</dependency>

DBのセットアップ

テーブルを作るためのDDLを用意する。

src/main/resources/schema.sql
CREATE TABLE IF NOT EXISTS todo (
    id IDENTITY
    ,title TEXT NOT NULL
    ,details TEXT
    ,finished BOOLEAN NOT NULL
);

ドメインオブジェクトの作成

TODOテーブルのレコードを表現するTodoオブジェクトを作る。キー値を保持するプロパティに@Idを付与する。

src/main/java/com/example/demo/domain/Todo.java
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を継承するのがポイント。

src/main/java/com/example/demo/repository/TodoRepository.java
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オブジェクトを操作することができる。

参考:CrudRepositoryの抜粋
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ファイルのロケーションやタイプエイリアスの設定を行う。

src/main/resources/application.properties
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インスタンスを設定することで、ネームスペースの命名規約を変更することができる。

src/main/resources/com/example/demo/mapper/TodoMapper.xml
<?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>
参考:MyBatisContextの抜粋
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の実行もサポートされた。

src/main/java/com/example/demo/repository/TodoRepository.java
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:
現時点の実装では、数値(intlongなど)や真偽値といったドメインクラス以外の型を返り値に指定することができない(つまり・・・@Queryメソッドデレコード数を取得するSQLやレコードの存在チェックを行うようなSQLを指定することができない)。後述の「カスタム操作の追加」で紹介する方法で対応はできるけど・・・なんとなく考慮漏れっぽい気がするのでIssueあげてみようかな~。⇒ DATAJDBC-175

2018-03-08 : 数値(intlongなど)や真偽値といったドメインクラス以外の型(いわゆるシンプル型)を返り値として返却できるようになりました!! 内部の作り的には・・・SingleColumnRowMapper+Spring Data JDBCに適用したConversionServiceと連携して型の解決が行なわれます。ちなみに・・・この対応に伴いSpring Framework 5.0.4.RELEASE以上が必要になります。

カスタム操作の追加

Spring Dataには「カスタム操作(カスタムメソッド)を追加する仕組み」があり、この仕組みはSpring Data JDBCでも利用することができる。

カスタムインタフェースの作成

カスタム操作(カスタムメソッド)を定義するためのインタフェースを定義する。

src/main/java/com/example/demo/repository/CustomizedTodoRepository.java
package com.example.demo.repository;

import com.example.demo.domain.Todo;

public interface CustomizedTodoRepository {

    Iterable<Todo> findAllByFinished(boolean finished);

}

作成したインタフェースをTodoRepositoryで継承する。

src/main/java/com/example/demo/repository/TodoRepository.java
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を利用する場合は、以下のような実装クラスを作成する。

src/main/java/com/example/demo/repository/CustomizedTodoRepositoryImpl.java
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を利用する場合は、以下のような実装クラスを作成する。

src/main/java/com/example/demo/repository/CustomizedTodoRepositoryImpl.java
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定義も追加する。

src/main/resources/com/example/demo/mapper/TodoMapper.xml
<?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のセットアップ

関連オブジェクトを永続化するためのテーブルを作成する。

src/main/resources/schema.sql
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オブジェクトに関連付ける。

src/main/java/com/example/demo/domain/Activity.java
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;
    }
}
src/main/java/com/example/demo/domain/Todo.java
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に定義されているメソッドを利用して操作する。

CRUD操作の実行例
@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を定義する。

src/main/resources/com/example/demo/mapper/ActivityMapper.xml
<?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する仕組みになっている。

src/main/resources/com/example/demo/mapper/TodoMapper.xml
<?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機能を利用して関連オブジェクトをドメインオブジェクトへマッピングする。

src/main/resources/com/example/demo/mapper/TodoMapper.xml
<?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定義することで変更することができる。ここでは・・・Todotodo_idへ、Todo_keysort_orderというカラム名に変更する方法を紹介する。

まず・・・テーブルのカラム名を変更する。

src/main/resources/schema.sql
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の修正も必要となる。

src/main/resources/com/example/demo/mapper/ActivityMapper.xml
<?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>
src/main/resources/com/example/demo/mapper/TodoMapper.xml
<?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 : 1:Nの関係をもつN側のクラスに監査用のプロパティが存在する場合に常にエラーが発生する(データが0件でもエラーとなる)
  • 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 インタフェースを実装したオブジェクトをアプリケーションコンテキストに登録し、@EnableJdbcAuditingdateTimeProviderRef属性に登録したオブジェクトの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)の時点では、@Idを付与したプロパティの型がプリミティブだと正しく動作しない。(DATAJDBC-216) -> 2018-05-18 対応されました :grin:

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)。ちなみに・・スネークケースのカラムとキャメルケースのプロパティ名のマッピングのような一定のルールがある場合は、NamingStrategy を利用して名称の違いを吸収することもできる。 (→DATAJDBC-206対応に伴いスネークケースのカラム名とキャメルケースのプロパティ名のマッピングがデフォルト動作になった)

インタフェースベースの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あげてみようかな・・・)。