🦇

Komapperでクエリ結果を任意のクラスにマッピング

2023/12/30に公開

はじめに

KomapperはサーバサイドKotlinのためのORMです。

この記事ではKomapper v1.16.0で導入したクエリ結果を任意のクラスにマッピングする機能について紹介します。

この機能は、Hibernateで言えばselect new、Jooqで言えばPOJOsに記載されている機能に近いです。

"DTO projection"というキーワードで検索すると関連する情報が得られると思います。

何が嬉しいのか?

右から左へデータを移し替えるだけのような単純なコードを排除できるのが利点です。

後述するKotlinのDSLを使って構築するクエリにおいてはタイプセーフなマッピングもできます。

この記事で行うこと

AUTHORテーブルとBOOKテーブルを結合した結果を、BookWithAuthorNameというクラスにマッピングする例を示します。

事前準備

データベースにはH2 Database Engineを使います。

データベース

この記事で利用するデータベースのER図は次のとおりです。

DDLとサンプルデータ
-- author テーブルの作成
CREATE TABLE author (
    author_id INT PRIMARY KEY,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    birth_year INT,
    nationality VARCHAR(50)
);
-- book テーブルの作成
CREATE TABLE book (
    book_id INT PRIMARY KEY,
    title VARCHAR(200),
    author_id INT,
    publication_year INT,
    genre VARCHAR(100),
    isbn VARCHAR(20),
    FOREIGN KEY (author_id) REFERENCES author(author_id)
);
-- author テーブルへのデータ挿入
INSERT INTO author (author_id, first_name, last_name, birth_year, nationality) VALUES 
    (1, 'Haruki', 'Murakami', 1949, 'Japanese'),
    (2, 'J.K.', 'Rowling', 1965, 'British'),
    (3, 'George', 'Orwell', 1903, 'British');
-- book テーブルへのデータ挿入
INSERT INTO book (book_id, title, author_id, publication_year, genre, isbn) VALUES 
    (1, 'Kafka on the Shore', 1, 2002, 'Fiction', '978-1400079278'),
    (2, 'Norwegian Wood', 1, 1987, 'Fiction', '978-0375704024'),
    (3, 'Harry Potter and the Sorcerer''s Stone', 2, 1997, 'Fantasy', '978-0590353427'),
    (4, '1984', 3, 1949, 'Dystopian', '978-0451524935');

エンティティクラス

テーブルに対応するクラス(AuthorBook)および結果として取得するマッピング対象クラス(BookWithAuthorName)を定義します。

クラス定義
@KomapperEntity
data class Author(
    @KomapperId val authorId: Int,
    val firstName: String,
    val lastName: String,
    val birthYear: Int,
    val nationality: String
)

@KomapperEntity
data class Book(
    @KomapperId val bookId: Int,
    val title: String,
    val authorId: Int,
    val publicationYear: Int,
    val genre: String,
    val isbn: String
)

@KomapperEntity
@KomapperProjection
data class BookWithAuthorName(
    @KomapperId val bookId: Int,
    val title: String,
    val authorName: String,
)

なお、BookWithAuthorNameにアノテーションを付与したくない(できない)場合、次のように記述することも可能です。

data class BookWithAuthorName(
    val bookId: Int,
    val title: String,
    val authorName: String,
)

@KomapperEntityDef(BookWithAuthorName::class)
@KomapperProjection
data class BookWithAuthorNameDef(
    @KomapperId val bookId: Nothing,
)

上記のコードにおける大事なポイントは@KomapperProjectionというアノテーションが付与されていることです。このアノテーションはマッピングのための拡張関数をコンパイル時に生成します。

拡張関数のデフォルトの名前はselectAs + エンティティクラスの単純名であり、この例ではselectAsBookWithAuthorNameとなります。

多くのクラスにアノテーションを付与するのが面倒な人のために、アノテーションプロセッサのオプションもあります。

クエリの例

Komapperは以下の2種類のクエリについて、クエリの結果を任意のクラスにマッピングできます。

  1. KotlinのDSLを使って構築するクエリ
  2. SQLテンプレートを使って構築するクエリ

KotlinのDSLを使って構築するクエリの場合

クエリの最後で拡張関数であるselectAsBookWithAuthorNameを呼び出します。この関数の引数はBookWithAuthorNameクラスに対応しており、名前つき引数を使ってBookWithAuthorNameクラスでどの値を受け取るかを指定できます

// メタモデルの取得
val a = Meta.author
val b = Meta.book

// クエリの構築
val query = QueryDsl.from(b)
    .innerJoin(a) { b.authorId eq a.authorId }
    .orderBy(b.bookId)
    .selectAsBookWithAuthorName(
        bookId = b.bookId,
        title = b.title,
        authorName = concat(concat(a.firstName, " "), a.lastName)
    )

// クエリの実行
val list: List<BookWithAuthorName> = db.runQuery(query)

selectAsBookWithAuthorNameの呼び出しにおいて引数の数や型のチェックが行われるため、タイプセーフにマッピングを実現できていると言えます。

発行されるSQLは次のものになります。

select 
    t0_.book_id, 
    t0_.title,
    (concat((concat(t1_.first_name, ' ')), t1_.last_name))
from
    book as t0_ 
inner join
    author as t1_ 
    on (t0_.author_id = t1_.author_id) 
order by 
    t0_.book_id asc

SELECT句にはselectAsBookWithAuthorNameの引数で指定したプロパティに対応するカラムのみが登場しており、SQL上でカラムの選択(射影)が行われていることがわかります。

SQLテンプレートを使って構築するクエリの場合

次にSQLテンプレートを利用する場合を見ていきましょう。

クエリの最後で拡張関数selectAsBookWithAuthorNameを呼び出せばマッピングが実現できます(SQLテンプレートを使う場合、AuthorクラスやBookクラスおよびそれらのメタモデルは不要です)。

// SQLテンプレートを記述
val sql = """
    select 
        t0_.book_id, 
        t0_.title,
        (concat((concat(t1_.first_name, ' ')), t1_.last_name))
    from
        book as t0_ 
    inner join
        author as t1_ 
        on (t0_.author_id = t1_.author_id) 
    order by 
        t0_.book_id asc
""".trimIndent()

// クエリの構築
val query = QueryDsl.fromTemplate(sql).selectAsBookWithAuthorName()

// クエリの実行
val list: List<BookWithAuthorName> = db.runQuery(query)

上記の例では、SQLのSELECTリストのカラムの順序をBookWithAuthorNameクラスのコンストラクタのパラメーターの順序に合わせる必要がありますが、パラメータが多い場合、順序を保つのが難しいこともあるでしょう。

その場合、次のようにselectAsBookWithAuthorName関数に ProjectionType.NAMEを渡すことでSELECTリストのカラム名とコンストラクタのパラメータ名をマッピングすることも可能です。

// SQLテンプレートを記述
val sql = """
    select 
        t0_.title as title,
        (concat((concat(t1_.first_name, ' ')), t1_.last_name)) as author_name,
        t0_.book_id as book_id, 
    from
        book as t0_ 
    inner join
        author as t1_ 
        on (t0_.author_id = t1_.author_id) 
    order by 
        t0_.book_id asc
""".trimIndent()

// クエリの構築
val query = QueryDsl.fromTemplate(sql).selectAsBookWithAuthorName(ProjectionType.NAME)

// クエリの実行
val list: List<BookWithAuthorName> = db.runQuery(query)

上述のコード内のSQLテンプレートでは、SELECTリストの順番を入れ替えていますが、その代わりカラムに別名(BookWithAuthorNameクラスのプロパティに対応する名前)をつけています。

まとめ

Komapperのクエリ結果を任意のクラスにマッピングする機能を紹介しました。

質問や改善案などあれば、本記事のコメント欄やKotlinのkomapperチャネルなどでお気軽にコメントください。
https://kotlinlang.slack.com/messages/komapper/

Discussion