Open7

Doma2を使えるようにしたい

okrokr

追加

メソッドの戻り値について

2つのパターンがある

  • 変更可能エンティティ
  • 不変エンティティ

変更可能エンティティ

@Entity
@Table(name = "t_product")
class ProductEntity {
    var id: String = ""
    var name: String = ""
    var price: Int = 0
    var stock: Int = 0

    companion object {
        fun create(id: String, name: String, price: Int, stock: Int): ProductEntity {
            val product = ProductEntity()
            product.id = id
            product.name = name
            product.price = price
            product.stock = stock
            return product
        }
    }
}

DAO

@Dao
@ConfigAutowireable
interface ProductDao {
    //    @Insert(duplicateKeys = ["id"])
    @Insert
    fun insert(product: ProductEntity): Int
}

main

fun main(args: Array<String>) {
    val context: ApplicationContext = AnnotationConfigApplicationContext(
        ShoppingApplication::class.java)
    val productDao: ProductDao = context.getBean(ProductDao::class.java)

    val product = ProductEntity.create(
        id = "test_id",
        name = "product",
        price = 100,
        stock = 10
    )
    val result = productDao.insert(product)
    println(result)
}

実行結果

> Task :bootRun
20:42:24.641 [main] INFO org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseFactory -- Starting embedded database: url='jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
1

戻り値をUnitにするとエラーになる

エラー: [DOMA4001] The return type must be int that indicates the affected rows count.

戻り値をResultにするとエラーになる

エラー: [DOMA4001] The return type must be int that indicates the affected rows count.

不変エンティティ

エンティティクラスを要素とするorg.seasar.doma.jdbc.Resultを戻り値にしないといけない
Entity

@Entity(immutable = true) // 不変エンティティ
@Table(name = "t_product")
data class ProductEntity(
    @Id
    val id: String,
    val name: String,
    val price: Int,
    val stock: Int
)

Dao

@Dao
@ConfigAutowireable
interface ProductDao {
    @Insert
    fun insert(product: ProductEntity): Result<ProductEntity>
}

main

fun main(args: Array<String>) {
    val context: ApplicationContext = AnnotationConfigApplicationContext(
        ShoppingApplication::class.java)
    val productDao: ProductDao = context.getBean(ProductDao::class.java)

    val product = ProductEntity(
        id = "test_id",
        name = "product",
        price = 100,
        stock = 10
    )
    val result = productDao.insert(product)
    println(result.entity)
}

実行結果

> Task :bootRun
20:04:10.682 [main] INFO org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseFactory -- Starting embedded database: url='jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
ProductEntity(id=test_id, name=product, price=100, stock=10)

Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

For more on this, please refer to https://docs.gradle.org/8.5/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.

BUILD SUCCESSFUL in 1s
10 actionable tasks: 5 executed, 5 up-to-date
1月 04, 2025 8:04:10 午後 com.okr.shopping.infla.doma.dao.ProductDaoImpl insert
情報: [DOMA2220] ENTER  : CLASS=com.okr.shopping.infla.doma.dao.ProductDaoImpl, METHOD=insert
1月 04, 2025 8:04:10 午後 com.okr.shopping.infla.doma.dao.ProductDaoImpl insert
情報: [DOMA2076] SQL LOG : PATH=[null],
insert into t_product (id, name, price, stock) values ('test_id', 'product', 100, 10)
1月 04, 2025 8:04:10 午後 com.okr.shopping.infla.doma.dao.ProductDaoImpl insert
情報: [DOMA2221] EXIT   : CLASS=com.okr.shopping.infla.doma.dao.ProductDaoImpl, METHOD=insert
20:04:10:  'bootRun' の実行を完了しました。

DAOのメソッドをIntにするとエラーになる

/shopping/build/tmp/kapt3/stubs/main/com/okr/shopping/infla/doma/dao/ProductDao.java:9: エラー: [DOMA4222] When the immutable entity class is a parameter type for the method annotated with such as @Insert, @Update and @Delete, the return type must be org.seasar.doma.jdbc.Result. The type argument of org.seasar.doma.jdbc.Result must be same entity class with the parameter type of the method.
    public abstract int insert(@org.jetbrains.annotations.NotNull()

DAOのメソッドをUnitにするとエラーになる

/shopping/build/tmp/kapt3/stubs/main/com/okr/shopping/infla/doma/dao/ProductDao.java:9: エラー: [DOMA4222] When the immutable entity class is a parameter type for the method annotated with such as @Insert, @Update and @Delete, the return type must be org.seasar.doma.jdbc.Result. The type argument of org.seasar.doma.jdbc.Result must be same entity class with the parameter type of the method.
    public abstract void insert(@org.jetbrains.annotations.NotNull()
okrokr

追加処理には2パターン

  • 自動生成されるSQLを利用する
  • 手動生成のSQLを利用するパターン

自動生成されるSQL

  • パラメータの型はEntityでなければならない
  • NULLであってはならない
  • パラメータは1つだけ
okrokr

Error

エラーが起きた場合の挙動

プライマリーキーが重複したデータをInsertしてみる

data.sql

INSERT INTO sample
(id, name) VALUES
('1', 'sample01')
;

main関数

try {
        sampleDao.insert(
            Sample(
                id = "1",
                name = "sample"
            )
        )
    } catch (e: Exception) {
        // 例外クラス名を出力
        println("スローされたクラスは::"+e.javaClass.name)
    }

実行結果

> Task :bootRun
11:07:40.694 [main] INFO org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseFactory -- Starting embedded database: url='jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
11:07:40.777 [main] WARN org.seasar.doma.boot.autoconfigure.DomaAutoConfiguration -- StandardDialect was selected because no explicit configuration and it is not possible to guess from 'spring.datasource.url property'
スローされたクラスは::org.seasar.doma.jdbc.UniqueConstraintException

domaの例外クラスがスローされた

okrokr

Entity

Entityはテーブルか検索結果のセットに対応するもの

命名規則

@Entityでクラスを作る場合
テーブル名: sample
Entityクラス名: Sample
ただし、DBには大文字小文字を区別するものと区別しないものや設定によって変更できたりするので、
データベースの種類にあまり依存しないようにするために@Tableを利用した方が良さそう

実験1

以下のようにテーブル名とEntityクラス名を一致させない
テーブル名: sample_table
Entityクラス名: Sample

create ta if not exists sample_table (
    id varchar(100) primary key,
    name varchar(100)
);

INSERT INTO sample_table
(id, name) VALUES
('i01', 'o01')
;

Entity

@Entity(immutable = true)
data class Sample(
    val id: String,
    val name: String,
)

DAO

@Dao
@ConfigAutowireable
interface SampleDao {
    @Select
    fun selectAll(): List<Sample>
}

selectAll.sql

select * from sample_table

main関数

fun main(args: Array<String>) {
    val context: ApplicationContext = AnnotationConfigApplicationContext(
        ShoppingApplication::class.java
    )
    val sampleDao: SampleDao = context.getBean(SampleDao::class.java)
    sampleDao.selectAll().forEach {
        println(it)
    }
}

実行結果

> Task :bootRun
21:44:58.360 [main] INFO org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseFactory -- Starting embedded database: url='jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
Sample(id=i01, name=o01) // データを取得できている

SELECTのSQLを手動で作成する場合は、テーブル名はsqlに書かれているので、命名規則を意識する必要はない
Entityに検索結果をマッピングしてくれる

実験2

実験1と同様に以下のようにテーブル名とEntityクラス名を一致させない
テーブル名: sample_table
Entityクラス名: Sample

今度はSQLを自動生成するインサートメソッドを行う

Entity

@Entity(immutable = true)
data class Sample(
    val id: String,
    val name: String,
)

DAO

@Dao
@ConfigAutowireable
interface SampleDao {
    @Insert
    fun insert(entity: Sample): Result<Sample>
}

main関数

fun main(args: Array<String>) {
    val context: ApplicationContext = AnnotationConfigApplicationContext(
        ShoppingApplication::class.java
    )
    val sampleDao: SampleDao = context.getBean(SampleDao::class.java)
    sampleDao.selectAll().first().copy(id = "new id", name = "new name").let {
        sampleDao.insert(it)
    }
}

実行結果

Exception in thread "main" org.seasar.doma.jdbc.JdbcException: [DOMA2016] Cannot get java.sql.PreparedStatement.
PATH=[null].
SQL=[insert into Sample (id, name) values (?, ?)].
The cause is as follows: org.h2.jdbc.JdbcSQLSyntaxErrorException: テーブル "SAMPLE" が見つかりません
Table "SAMPLE" not found; SQL statement:
insert into Sample (id, name) values (?, ?) [42102-224]
	at org.seasar.doma.internal.jdbc.util.JdbcUtil.prepareStatement(JdbcUtil.java:37)
	at org.seasar.doma.jdbc.command.ModifyCommand.prepareStatement(ModifyCommand.java:75)
	at org.seasar.doma.jdbc.command.ModifyCommand.execute(ModifyCommand.java:46)
	at com.okr.shopping.infla.doma.dao.SampleDaoImpl.insert(SampleDaoImpl.java:84)
	at com.okr.shopping.ShoppingApplicationKt.main(ShoppingApplication.kt:55)
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: テーブル "SAMPLE" が見つかりません

insert into Sample (id, name) values (?, ?)のクエリが実行されたため、テーブルがないよというエラーが発生
Entity名がそのままテーブル名として扱われているのがわかる

okrokr

よくデータベース設計をする際の命名規則としては小文字で_区切りで単語を区切ることが多いので、
@Entity(naming = NamingType.SNAKE_LOWER_CASE)を使うと明示的に指定が不要になる
Entity

@Entity(immutable = true, naming = NamingType.SNAKE_LOWER_CASE)
data class SampleTable(
    val id: String,
    val name: String,
)

ただ、domaCodeGenというGradleのプラグインが配布されており、DBスキーマを読み込んでコードを自動生成してくれて、テーブル名とカラム名に関しても自動で明示的に設定する記述を書いてくれるので、それを利用するのが良さそう。

okrokr

SpringBootとdomaの関係

クラス図

domaのConfigクラス及び実装クラスのDomaConfigがHikariDatasourceをdomaに提供して、コネクションプールやjdbcドライバをdomaから呼び出せるようにしている。
doma-spring-boot-starterを入れれば、springbootでdatasourceをDIコンテナに登録すればよしなにやってくれるということを理解してれば良さそう

okrokr

検索

ページネーション

検索オプションを利用する。
MicroSoft SQLServerとDB2の場合、SQLファイルの記述に条件がある

SelectOptionsでoffsetとlimitを指定して、それをDAOメソッドの引数で渡す。
Null許容にしておけば、どっちでも使えるので、Selectでは大体入れておくと便利かも?
DAO

@Dao
@ConfigAutowireable
interface ProductDao {
    // SelectOptionsをnull許容で定義しておくと便利そう
    @Select
    fun selectAll(options: SelectOptions?): List<ProductEntity>
}

main関数

fun main(args: Array<String>) {
    val context: ApplicationContext = AnnotationConfigApplicationContext(
        ShoppingApplication::class.java
    )
    val productDao = context.getBean(ProductDao::class.java)
    val dialect = context.getBean(Dialect::class.java)
    println(dialect.javaClass.name)
    try {
        println("商品一覧")
        productDao.selectAll().forEach { println(it) } // SelectOptionsなし
        println("商品一覧(2件)")
        productDao.selectAll(SelectOptions.get().offset(1).limit(2)).forEach { println(it) } SelectOptionsあり
    } catch (e: Exception) {
        // 例外クラス名を出力
        println("スローされたクラスは::"+e.javaClass.name)
        println(e.message)
    }
}

検索結果とEntity

検索結果をEntityにマッピングする時はカラム名と一致する命名規則のプロパティまたは@Columnで指定したnameのついたプロパティにマッピングされる。
ただし、複数テーブルをjoinした検索結果でカラム名が同じものがあり、@Tableと@Columnを指定しない場合、joinされる側が有効になった。
sampleテーブル

mysql> describe sample;
+-------+--------------+------+-----+---------+-------+
| Field | Type         | Null | Key | Default | Extra |
+-------+--------------+------+-----+---------+-------+
| id    | varchar(100) | NO   | PRI | NULL    |       |
| name  | varchar(100) | YES  |     | NULL    |       |
+-------+--------------+------+-----+---------+-------+
mysql> select * from sample;
+----+----------+
| id | name     |
+----+----------+
| 1  | sample01 |
+----+----------+

sample_subテーブル

mysql> describe sample_sub;
+-----------+--------------+------+-----+---------+-------+
| Field     | Type         | Null | Key | Default | Extra |
+-----------+--------------+------+-----+---------+-------+
| id        | varchar(100) | NO   | PRI | NULL    |       |
| parent_id | varchar(100) | YES  | MUL | NULL    |       |
| sub_name  | varchar(100) | YES  |     | NULL    |       |
+-----------+--------------+------+-----+---------+-------+

mysql> select * from sample_sub;
+-----+-----------+------------+
| id  | parent_id | sub_name   |
+-----+-----------+------------+
| s01 | 1         | sub_name01 |
+-----+-----------+------------+

SQL

select * from sample join sample_sub on sample.id = sample_sub.parent_id

idカラムが被っている
Entity

@Entity(immutable = true)
@Table(name = "sample")
data class SampleJoin(
    val id: String,
    val name: String,
    @Column(name = "parent_id")
    val parentId: String,
    @Column(name = "sub_name")
    val subName: String,
)

dao

@Dao
@ConfigAutowireable
interface SampleJoinDao {
    @Select
    fun selectSampleJoin(): List<SampleJoin>
}

main関数

fun main(args: Array<String>) {
    val context: ApplicationContext = AnnotationConfigApplicationContext(
        ShoppingApplication::class.java
    )
    val sampleJoinDao = context.getBean(SampleJoinDao::class.java)
    sampleJoinDao.selectSampleJoin().forEach {
        println(it)
    }
}

1とs01どっちがセットされる?

> Task :bootRun
SampleJoin(id=s01, name=sample01, parentId=1, subName=sub_name01)

joinされた側のsample_subテーブルのカラムが指定された。
からならずなのかドキュメントに特に記載がない
コード的にはここ

protected ENTITY build(ResultSet resultSet) throws SQLException {
    assertNotNull(resultSet);
    if (indexMap == null) {
      indexMap = createIndexMap(resultSet.getMetaData(), entityType);
    }
    Map<String, Property<ENTITY, ?>> states = new HashMap<>(indexMap.size());
    for (Map.Entry<Integer, EntityPropertyType<ENTITY, ?>> entry : indexMap.entrySet()) {
      Integer index = entry.getKey();
      EntityPropertyType<ENTITY, ?> propertyType = entry.getValue();
      Property<ENTITY, ?> property = propertyType.createProperty();
      fetch(resultSet, property, index, jdbcMappingVisitor);
      states.put(propertyType.getName(), property); // <=ここでカラム名をキーにしたマップに詰めている
    }
    ENTITY entity = entityType.newEntity(states);
    if (!entityType.isImmutable()) {
      entityType.saveCurrentStates(entity);
    }
    return entity;
  }

でEntityに詰め替えているが、Mapで値を持っていて、カラム名をキーにしているので、最後に詰められたものが勝つようになっている見たい。
順序的には、selectの結果のカラムの順番による