October 30, 2019

Kotlin Multiplatform ProjectでSQLDelightを使う

Kotlin Multiplatform Project(MPP)で、Databaseを利用したい場合は現状SQLDelightというライブラリがオススメです。
2019年10月現在では、Android, iOS, JVMのサポートをしています。
SQLDelightは使い方自体はとても簡単なので、経験者の方は公式ドキュメントで細かい使い方を見ると良いと思います。
この記事はどちらかというとMPP初心者向けです。

GitHub - cashapp/sqldelight: SQLDelight - Generates typesafe Kotlin APIs from SQL

SQLDelight - Generates typesafe Kotlin APIs from SQL - cashapp/sqldelight

SQLDelightとは

SQLDelight自体は2016年から存在しています。
その頃はRxJavaの全盛期であり、Reactiveなラッパーライブラリであったり、Realm のようなNoSQLライブラリも出始め、 SQLDelightは他のDBライブラリと比較してあまり優位性を持ててはいませんでした。(主観)
しかし2018年になり、Kotlin Conf2018のセッションでJakeとAlecによってKotlin Multiplatform Projectへの対応発表を機に急激に優位性を増したように思います。(主観)
元々はhttps://github.com/square/sqldelight にあったのですが、いつの間にかSquareのサービスであるCash App ブランドのリポジトリに移動してました。
深い意味はなさそうですが、Squareから出向したエンジニアがこっちの方が自由だからって感じで移動したのかもしれません。(推測)
ブログ を見るとCashAppで利用するために移動していそうな感じがします。

使い方

今回は、Greetingという名前のデータベースを作成します。 カラムはシンプルにid, hello, byeの3つのみです。

idhellobye
1Good afternoonGood bye
2BonjourAu revoir
3Guten TagAuf Wiedersehen

Gradle

SQLDelightはSQLの生クエリからKotlinのコード生成を行ってくれます。
初めにコード生成を行うためのプラグインをルートのbuild.gradleに定義します。 // A

buildscript {
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.0'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        ...
        classpath 'com.squareup.sqldelight:gradle-plugin:1.2.0' // A
    }
}

MPPの共通モジュールのgradleファイルで上記で定義したプラグインを読み込みます。 // B

plugins {
    id 'kotlin-multiplatform'
    ...
    id 'com.squareup.sqldelight' // B
}

// C
sqldelight {
    GreetingDatabase {
        packageName = "com.github.aakira.mpp.common"
        sourceFolders = ["sqldelight"]
    }
}

// D
kotlin {
    ...
    sourceSets {
        androidMain {
            dependencies {
                 implementation "com.squareup.sqldelight:android-driver:1.2.0"
            }
        }
        iosMain {
            dependencies {
                 implementation "com.squareup.sqldelight:ios-driver:1.2.0"
            }
        }
        jvmMain {
            dependencies {
                 implementation "com.squareup.sqldelight:sqlite-driver:1.2.0"
            }
        }
    }
}

Greetingという名前のデータベースを作成します。 // C
次項で説明するSQLの定義ファイルをこのパッケージに定義します。

Android, iOS, JVMそれぞれでSQLDelightのライブラリを読み込みます。 // D

SQLの定義

前項C部分で定義したパッケージ(/common/src/commonMain/sqldelight/com/github/aakira/mpp/common)にSQLのクエリファイルGreeting.sqを作成します。
sourceFoldersがrootの階層になるので、Treeを見るとこのようになります。

.
├── android
├── ios
├── jvm
├── js
├── common
│   ├── build.gradle
│   └── src
│       ├── androidMain
│       ├── commonMain
│       │   ├── kotlin
│       │   │   └── com
│       │   │      └── github
│       │   │          └── aakira
│       │   │              └── mpp
│       │   │                  └── common
│       │   │                      ├── ...
│       │   │                      └── Greeting.kt
│       │   └── sqldelight 
│       │       └── com
│       │           └── github
│       │               └── aakira
│       │                   └── mpp
│       │                       └── common
│       │                           └── Greeting.sq    // E
│       ├── iosMain
│       ├── jsMain
│       └── jvmMain
├── build.gradle
└── settings.gradle

Greeting.sqには普通のSQL文を定義しています。 CREATE TABLEを最初に書いて、あとはタグ名とクエリを書きます。

CREATE TABLE Greeting(
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    hello TEXT NOT NULL,
    bye TEXT NOT NULL
);

insertItem:
INSERT OR REPLACE INTO Greeting(hello, bye) VALUES(?,?);

selectAll:
SELECT * FROM Greeting;

selectById:
SELECT * FROM Greeting WHERE id = ?;

SQLファイルを記述さえすれば、 generateAndroidDebugGreetingDatabaseInterface のようなGradle Taskを実行するか、一度ビルドタスクを走らせることでSQLDelightがKotlinのファイルを生成します。
現状だと、このSQLファイルからは以下の4つが生成されます。

  • Greeting - テーブルのKotlin data class
  • GreetingDatabase - Databaseのinterface定義
  • GreetingQueries - SQL Queryのinterface定義
  • GreetingDatabaseImpl - GreetingDatabase, GreetingQueriesの実装クラス等(複数)


private class GreetingDatabaseImpl(
  driver: SqlDriver
) : TransacterImpl(driver), GreetingDatabase {
  override val greetingQueries: GreetingQueriesImpl = GreetingQueriesImpl(this, driver)

  object Schema : SqlDriver.Schema {
    override val version: Int
      get() = 1

    override fun create(driver: SqlDriver) {
      driver.execute(null, """
          |CREATE TABLE Greeting(
          |    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
          |    hello TEXT NOT NULL,
          |    bye TEXT NOT NULL
          |)
          """.trimMargin(), 0)
    }

    override fun migrate(
      driver: SqlDriver,
      oldVersion: Int,
      newVersion: Int
    ) {
    }
  }
}

このような感じで生成されていて、SQL文がハードコーディングされています。
GreetingQueriesImplにも同様にSelect文等がハードコーディングされています。

Kotlinコード

Common

SQLDelightによって自動生成されたコードを使って、実際にDBを操作していきます。

package com.github.aakira.mpp.common

import com.github.aakira.mpp.common.model.Greeting

expect fun createDb(): GreetingDatabase? // A

internal const val dbName: String = "greeting.db" // B

class GreetingDao {

    private val greetingDatabase = createDb()
    private val queries = greetingDatabase?.greetingQueries

    // C
    fun storeGreeting(hello: String, bye: String) {
        queries?.insertItem(hello, bye)
    }

    // D
    fun getGreetings(): List<Greeting> {
        val queries = queries ?: return listOf()

        return queries.selectAll(mapper = { _, hello, bye ->
            Greeting(hello, bye)
        }).executeAsList()
    }
}

A. Databaseのインスタンス生成はプラットフォーム毎に異なる為、expectで定義しています

B. インスタンス生成時にDatabaseの名前を指定する必要があるのでCommonで定義しています 現状SQLDelightはJavaScriptに対応していません。 そのためJavaScriptをTargetに追加しているプロジェクトではDatabaseを生成することが出来ないためNullableになっています。

C, D. 自動生成されたコードを経由して保存と取得するメソッドを定義しています

Actual

Commonの共通コードが書けたので、Actualの実装をしていきます

  • Android
lateinit var mppAppContext: Context

actual fun createDb(): GreetingDatabase? =
    GreetingDatabase(AndroidSqliteDriver(GreetingDatabase.Schema, mppAppContext, dbName))
  • iOS
actual fun createDb(): GreetingDatabase? = GreetingDatabase(NativeSqliteDriver(GreetingDatabase.Schema, dbName))
  • JVM
actual fun createDb(): GreetingDatabase? = GreetingDatabase(JdbcSqliteDriver(IN_MEMORY))
  • JavaScript
actual fun createDb(): GreetingDatabase? = null

Caller

  • Android

override fun onCreate(savedInstanceState: Bundle?) {

  val dao = GreetingDao()

  // store
  dao.storeGreeting("hello", "bye")

  // get
  val greetings = dao.getGreetings()
}
  • iOS
override func viewDidLoad() {
  
  let dao = GreetingDao()

  // store
  dao.storeGreeting(hello: "hello", bye: "bye")

  // get
  let greetings = dao.getGreetings()
}

AndroidもiOSもこれだけで簡単にDatabaseに値を保存することが出来ました!!
SQLDelight素晴らしい!

おまけ

SQLDelightはCoroutinesのFlow もサポートしています。
Flowを使うと何が嬉しいのかというと、DBの値の変化を監視して変更通知を受け取ることが出来ます。
Flowを使いたい場合は、Commonモジュールのbuild.graldeのdependenciesに追加します。

...
  kotlin {
      ...
      sourceSets {
          androidMain {
              dependencies {
                 ...
                 implementation "com.squareup.sqldelight:coroutines-extensions:1.2.0"
              }
          }
      }
  }
...

Dao側のメソッドをFlowに変更

class GreetingDao {

    fun getGreetingsFlow(): Flow<Query<Greeting>> {
        val queries = queries ?: return flowOf()

        return queries.selectAll(mapper = { _, hello, bye ->
            Greeting(hello, bye)
        }).asFlow()
    }
}
  • 呼び出し側

(lifecycleScopeはAndroidのlifecycle-runtime-ktxにあるCroutineScopeです。ここでは詳しく説明はしません。)


override fun onCreate(savedInstanceState: Bundle?) {
    val greetings = dao.getGreetingsFlow()

    lifecycleScope.launch {
        greetings.collect { query ->
            query.executeAsList().forEach {
                Log.v("Greeting Flow", it.hello)
            }
        }
    }
}

先程作成したselectAllのメソッドをasFlow()に変更してあげれば、呼び出し側でFlowを利用することが可能です。
これで、DBの中身に変更があるとgreetings.collectが毎回発火して自動でDBの値が流れてきます。 最近はCoroutinesばかり使っているので、この感じが もはや懐かしくて良いですね(Rx lover)
Kotlin/Nativeはまだ対応していませんので、HMPP に期待しましょう。

大したことは書いていませんが、sqldelight-exampleブランチに今回のサンプルがあります。

GitHub - AAkira/mpp-example at sqldelight-example

This project is a minimum example of Kotlin Multiplatform Project. - GitHub - AAkira/mpp-example at sqldelight-example

© AAkira 2023