🏠

SpringBoot + Doma3 で CRUD してみる

2025/02/16に公開

はじめに

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

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/defaultMETA-INF 以下を配置すればとりあえず怒られなくなりますが、本質的な解消にはならなそうです。

build/classes 以下には META-INF は出力されているので、doma が認識するパスとは異なるみたいです。

spring-boot-sample に何かないかなーと思うと

https://github.com/domaframework/spring-boot-sample/commit/88838716d7ae93894c4a0ee75c88e6df77b89371

にそれっぽいコミットがありました。

これを 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