SpringBoot + Doma3 で CRUD してみる
はじめに
Doma を使った SpringGoot の記事では、Doma2 を扱っているものが多く、Doma3 がパッと検索した感じでなかったため、doma公式の https://github.com/domaframework/simple-examples をそのまま流用して SpringBoot で簡単な CRUD で試してみました。
本稿は、SQLファイルを使った例です。
環境
- JDK 21.0.5
- Gradle 8.12.1
- SpringBoot 3.4.2
- doma 3.4.0
構成
とりあえず動くものは以下のような構成になります。
SQLファイルのパスは、完全修飾クラス名と完全に一致させる必要があります。
今回の例では、 com.example.doma3.repository.EmployeeRepository
なので、SQLファイルは META-INF/com/example/doma3/repository/EmployeeRepository/
以下に配置します。
src
├── main
│ ├── java
│ │ └── com
│ │ └── example
│ │ └── doma3
│ │ ├── DbInitializer.java
│ │ ├── DomaApplication.java
│ │ ├── controller
│ │ │ └── EmployeeController.java
│ │ ├── domain
│ │ │ ├── Age.java
│ │ │ ├── AgeConverter.java
│ │ │ ├── DomainConverterProvider.java
│ │ │ └── Salary.java
│ │ ├── entity
│ │ │ ├── Department.java
│ │ │ ├── Employee.java
│ │ │ ├── EmployeeListener.java
│ │ │ └── JobType.java
│ │ ├── input
│ │ │ └── EmployeeInput.java
│ │ ├── repository
│ │ │ ├── EmployeeRepository.java
│ │ │ └── ScriptRepository.java
│ │ └── service
│ │ └── EmployeeService.java
│ └── resources
│ ├── META-INF
│ │ └── com
│ │ └── example
│ │ └── doma3
│ │ └── repository
│ │ ├── EmployeeRepository
│ │ │ ├── delete.sql
│ │ │ ├── insert.sql
│ │ │ ├── selectAll.sql
│ │ │ ├── selectById.sql
│ │ │ └── update.sql
│ │ └── ScriptRepository
│ │ ├── create.script
│ │ └── drop.script
│ ├── application.properties
│ ├── static
│ └── templates
└── test
└── java
└── com
└── example
└── doma
└── DomaApplicationTests.java
build.gradle.kts
plugins {
java
id("org.springframework.boot") version "3.4.2"
id("io.spring.dependency-management") version "1.1.7"
id("org.domaframework.doma.compile") version "3.0.1"
id("eclipse")
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
repositories {
mavenCentral()
}
eclipse {
classpath {
defaultOutputDir = file("$buildDir/classes/java/main")
}
}
tasks {
compileJava {
options.compilerArgs.addAll(listOf(
"-Adoma.domain.converters=com.example.doma3.domain.DomainConverterProvider"
))
}
}
dependencies {
implementation("org.seasar.doma:doma-core:3.4.0")
annotationProcessor("org.seasar.doma:doma-processor:3.4.0")
implementation("org.seasar.doma.boot:doma-spring-boot-starter:2.1.0")
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.named<Test>("test") {
useJUnitPlatform()
}
application.properties
domaframework/doma-spring-boot: Spring Boot Support for Doma を参考にします。
spring.application.name=doma3
doma.dialect=H2
doma.sql-file-repository=NO_CACHE
EmployeeRepository.java
- dao-style-file の dao に自動生成されたDAOの実装クラスをDIコンテナ管理対象にするためのアノテーション
@ConfigAutowireable
を付与します。
package com.example.doma3.repository;
import com.example.doma.domain.Age;
import com.example.doma.domain.Salary;
import com.example.doma.entity.Department;
import com.example.doma.entity.Employee;
import java.time.LocalDateTime;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Stream;
import org.seasar.doma.AggregateStrategy;
import org.seasar.doma.AssociationLinker;
import org.seasar.doma.Dao;
import org.seasar.doma.Delete;
import org.seasar.doma.Insert;
import org.seasar.doma.Select;
import org.seasar.doma.SelectType;
import org.seasar.doma.Update;
import org.seasar.doma.boot.ConfigAutowireable;
import org.seasar.doma.jdbc.SelectOptions;
@Dao
@ConfigAutowireable
public interface EmployeeRepository {
@Select
Employee selectById(Integer id);
@Select
List<Employee> selectAll();
@Insert(sqlFile = true)
int insert(Employee employee);
@Update(sqlFile = true)
int update(Employee employee);
@Delete(sqlFile = true)
int delete(Employee employee);
}
ScriptRepository.java
- こちらも dao-style-file の dao に
@ConfigAutowireable
を付与します。
package com.example.doma3.repository;
import org.seasar.doma.Dao;
import org.seasar.doma.Script;
import org.seasar.doma.boot.ConfigAutowireable;
@Dao
@ConfigAutowireable
public interface ScriptRepository {
@Script
void create();
@Script
void drop();
}
DbInitializer.java
- SpringBoot 起動時に script を実行させたかったので、Spring Boot CommandLineRunnerを使った初期処理の実行 #Java - Qiita を参考にしました。
package com.example.doma3;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import com.example.doma3.repository.ScriptRepository;
@Component
public class DbInitializer implements CommandLineRunner {
private final ScriptRepository scriptRepository;
public DbInitializer(ScriptRepository scriptRepository) {
this.scriptRepository = scriptRepository;
}
@Override
public void run(String... args) throws Exception {
// 既存のテーブルがある場合は削除
try {
scriptRepository.drop();
} catch (Exception e) {
// 初回起動はエラー吐くけどスルー
}
scriptRepository.create();
}
}
EmployeeInput.java
package com.example.doma3.input;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import com.example.doma.entity.JobType;
public class EmployeeInput {
private Integer id;
@NotBlank(message = "名前は必須です")
@Size(max = 100, message = "名前は100文字以内で入力してください")
private String name;
@NotNull(message = "年齢は必須です")
@Min(value = 18, message = "年齢は18歳以上である必要があります")
private Integer age;
@NotNull(message = "給与は必須です")
@Min(value = 0, message = "給与は0以上である必要があります")
private Integer salary;
@NotNull(message = "職種は必須です")
private JobType jobType;
@NotNull(message = "入社日は必須です")
@Past(message = "入社日は過去の日付である必要があります")
private LocalDate hiredate;
@NotNull(message = "部署IDは必須です")
private Integer departmentId;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public Integer getSalary() {
return salary;
}
public void setSalary(Integer salary) {
this.salary = salary;
}
public JobType getJobType() {
return jobType;
}
public void setJobType(JobType jobType) {
this.jobType = jobType;
}
public LocalDate getHiredate() {
return hiredate;
}
public void setHiredate(LocalDate hiredate) {
this.hiredate = hiredate;
}
public Integer getDepartmentId() {
return departmentId;
}
public void setDepartmentId(Integer departmentId) {
this.departmentId = departmentId;
}
}
EmployeeService.java
package com.example.doma3.service;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.doma.domain.Age;
import com.example.doma.domain.Salary;
import com.example.doma.entity.Employee;
import com.example.doma.input.EmployeeInput;
import com.example.doma.repository.EmployeeRepository;
@Service
public class EmployeeService {
private final EmployeeRepository employeeRepository;
public EmployeeService(EmployeeRepository employeeRepository) {
this.employeeRepository = employeeRepository;
}
public List<Employee> findAll() {
return employeeRepository.selectAll();
}
public Employee findById(Integer id) {
return employeeRepository.selectById(id);
}
@Transactional
public Integer update(EmployeeInput input) {
Employee existingEmployee = employeeRepository.selectById(input.getId());
existingEmployee.setName(input.getName());
existingEmployee.setAge(new Age(input.getAge()));
existingEmployee.setSalary(new Salary(input.getSalary()));
existingEmployee.setJobType(input.getJobType());
existingEmployee.setHiredate(input.getHiredate());
existingEmployee.setDepartmentId(input.getDepartmentId());
return employeeRepository.update(existingEmployee);
}
public Void delete(Integer id) {
Employee existingEmployee = employeeRepository.selectById(id);
employeeRepository.delete(existingEmployee);
return null;
}
public Integer register(EmployeeInput input) {
Employee employee = new Employee();
employee.setName(input.getName());
employee.setAge(new Age(input.getAge()));
employee.setSalary(new Salary(input.getSalary()));
employee.setJobType(input.getJobType());
employee.setHiredate(input.getHiredate());
employee.setDepartmentId(input.getDepartmentId());
employee.setVersion(1);
return employeeRepository.insert(employee);
}
}
EmployeeController.java
package com.example.doma3.controller;
import java.util.List;
import org.springframework.web.bind.annotation.RestController;
import com.example.doma3.entity.Employee;
import com.example.doma3.input.EmployeeInput;
import com.example.doma3.service.EmployeeService;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
@RestController
public class EmployeeController {
private final EmployeeService employeeService;
public EmployeeController(EmployeeService employeeService) {
this.employeeService = employeeService;
}
@GetMapping("/employees")
public List<Employee> getEmployees() {
return employeeService.findAll();
}
@GetMapping("/employees/{id}")
public Employee getEmployee(@PathVariable Integer id) {
return employeeService.findById(id);
}
@PutMapping("/employees/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void update(@PathVariable Integer id, @Validated @RequestBody EmployeeInput input) {
input.setId(id);
employeeService.update(input);
}
@PostMapping("/employees")
@ResponseStatus(HttpStatus.OK)
public void registerEmployee(
@Validated @RequestBody EmployeeInput input) {
employeeService.register(input);
}
@DeleteMapping("/employees/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Integer id) {
employeeService.delete(id);
}
}
create.script
- IDは(100番から)自動採番されるように修正。
3 - create table employee (id integer not null primary key,name varchar(255) not null,age integer not null,salary integer,job_type varchar(20),hiredate timestamp, department_id integer, version integer not null, insertTimestamp timestamp, updateTimestamp timestamp);
+ create table employee (id integer default next value for employee_seq primary key,name varchar(255) not null,age integer not null,salary integer,job_type varchar(20),hiredate timestamp, department_id integer, version integer not null, insertTimestamp timestamp, updateTimestamp timestamp);
insert.sql
- 上記の通りなのでIDを消す。
insert into Employee (
NAME,
AGE,
DEPARTMENT_ID,
HIREDATE,
JOB_TYPE,
SALARY,
INSERTTIMESTAMP,
UPDATETIMESTAMP,
VERSION
) values (
/* employee.name */'test',
/* employee.age */10,
/* employee.departmentId */1,
/* employee.hiredate */'2010-01-01',
/* employee.jobType */'SALESMAN',
/* employee.salary */300,
/* employee.insertTimestamp */'2010-01-01 12:34:56',
/* employee.updateTimestamp */'2010-01-01 12:34:56',
/* employee.version */1
)
update.sql
- バージョンがインクリメントされるように修正。
update
Employee
set
NAME = /* employee.name */'test',
AGE = /* employee.age */10,
DEPARTMENT_ID = /* employee.departmentId */1,
HIREDATE = /* employee.hiredate */date'2010-01-01',
JOB_TYPE = /* employee.jobType */'SALESMAN',
SALARY = /* employee.salary */300,
UPDATETIMESTAMP = /* employee.updateTimestamp */timestamp'2010-01-01 12:34:56',
VERSION = /* employee.version +1 */1 -- バージョンをインクリメント
where
ID = /* employee.id */1
and VERSION = /* employee.version */1 -- 現在のバージョンと一致することを確認
実行してみる
# GET
curl http://localhost:8080/employees
# GET
curl http://localhost:8080/employees/1
# {"id":1,"name":"ALLEN","age":{"value":30},"salary":{"value":1600},"jobType":"SALESMAN","hiredate":"2008-01-20","departmentId":1,
# "version":1,"insertTimestamp":"2025-02-16T09:46:28.296417","updateTimestamp":null,"department":null}
# PUT
curl -X PUT -v \
http://localhost:8080/employees/1 \
-H 'Content-Type: application/json' \
-d '{
"name": "ALLEN",
"age": 99,
"salary": 2000,
"jobType": "SALESMAN",
"hiredate": "2008-01-20",
"departmentId": 1,
"version": 1
}'
# * upload completely sent off: 158 bytes
# < HTTP/1.1 204
# GET
curl http://localhost:8080/employees/1
# versionがインクリメントされている
# {"id":1,"name":"ALLEN","age":{"value":99},"salary":{"value":2000},"jobType":"SALESMAN","hiredate":"2008-01-20","departmentId":1,
# "version":2,"insertTimestamp":"2025-02-16T09:57:53.419604","updateTimestamp":"2025-02-16T09:58:03.372212","department":null}
# POST
curl -v \
http://localhost:8080/employees \
-H 'Content-Type: application/json' \
-d '{
"name": "HogeFuga",
"age": 77,
"salary": 50000,
"jobType": "MANAGER",
"hiredate": "2024-01-01",
"departmentId": 1
}'
# * upload completely sent off: 129 bytes
# < HTTP/1.1 200
# DELETE
curl -X DELETE -v http://localhost:8080/employees/100
# * Request completely sent off
# < HTTP/1.1 204
やること
-
https://github.com/domaframework/simple-examples の dao-style-file からコピーする
-
resources
以下に META-INF 以下の構成を適宜修正して配置する(上記構成を参照) -
domain
,entity
,dao
をそのままプロジェクト直下に配置する(上記構成を参照)
-
- Controller, Service, Input を作る (ここらへんはいっぱいブログも記事もあるので割愛)
ハマったところ・その他
[DOMA4019] The file[META-INF/../select.sql] is not found from the classpath
- 注釈アノテーション(Annotation processing)
The import org.seasar.doma.Association cannot be resolvedJava(268435846)
[DOMA4019] The absolute path is bin/default/META-INF/
[DOMA4019] The file[META-INF/../select.sql] is not found
よくある質問 に挙げられています。
build.gradle(kts)
に設定します。
plugins {
id("org.domaframework.doma.compile") version "3.0.1"
}
dependencies {
implementation("org.seasar.doma:doma-core:3.3.0")
annotationProcessor("org.seasar.doma:doma-processor:3.3.0")
}
注釈アノテーション(Annotation processing)
ファイル配置しただけだと、DBとアプリケーション間の型変換となる DomainConverter の設定が不十分だと怒られるので
[DOMA4096] The class "com.example.doma3.domain.Age" is not supported as a
persistent type. If you intend to map the class to the external domain class with @ExternalDomain,
the configuration may be not enough.
Check the class annotated with @DomainConverters and the annotation processing option "doma.domain.converters".Java(0)
Gradle でのオプションの設定 にしたがって、 build.gradle(kts)
に設定します。
tasks {
compileJava {
options.compilerArgs.addAll(listOf(
"-Adoma.domain.converters=com.example.doma3.domain.DomainConverterProvider"
))
}
}
The import org.seasar.doma.Association cannot be resolvedJava(268435846)
simple-examples のバージョンにならって、 3.3.0
から 3.4.0
に修正します。
dependencies {
implementation("org.seasar.doma:doma-core:3.4.0")
annotationProcessor("org.seasar.doma:doma-processor:3.4.0")
}
この時点でいちおう build も通るし、動きます。
ただ、エラーが気持ち悪いので以下も修正したくなります。
[DOMA4019] The absolute path is bin/default/META-INF/
bin/default
に META-INF
以下を配置すればとりあえず怒られなくなりますが、本質的な解消にはならなそうです。
build/classes
以下には META-INF
は出力されているので、doma が認識するパスとは異なるみたいです。
spring-boot-sample に何かないかなーと思うと
にそれっぽいコミットがありました。
これを build.gradle(kts)
に適用します。
plugins {
java
id("org.springframework.boot") version "3.4.2"
id("io.spring.dependency-management") version "1.1.7"
id("org.domaframework.doma.compile") version "3.0.1"
id("eclipse")
}
...省略
eclipse {
classpath {
defaultOutputDir = file("$buildDir/classes/java/main")
}
}
これでエラーは消えました!
さいごに
Doma は日本の方 が開発されているため、日本語ドキュメントが充実していてありがたいですね。(英語読める読めないは置いといて、やっぱり母国語の方が負荷は小さいです)
とりあえずDB連携して CRUD 使えるようになったので、もう少し SpringBoot も含めて仕組みを深堀っていきたいと思うところです。
Discussion