【Android】ローカルDBに日時を保存する方法と注意点

## SQLite と日時型

Android 標準のローカルデータベースは SQLite です。
[Room ライブラリ](https://developer.android.com/training/data-storage/room?hl=ja)を使った場合も内部では SQLite が使用されます。

SQLite には日時型がありません。
　→[Datatypes In SQLite](https://www.sqlite.org/datatype3.html)
日時を保存するには、SQLite で使えるいずれかの型に変換してやる必要があります。

SQLite の `datetime` 関数は `YYYY-MM-DD HH:MM:SS` 形式の TEXT 型の値を返します。
　→[Date And Time Functions](https://www.sqlite.org/lang_datefunc.html)
SQL で `datetime` 関数との比較などを行えるようにするため、
ローカル DB に保存するのはこれと同じ形式にするのがよいでしょう。


### Room と日時型

Room の Entity に日時型のプロパティを持たせることを考えます。

日時型としては [`Instant` クラス](https://docs.oracle.com/javase/jp/8/docs/api/java/time/Instant.html)を使うのがよいでしょう[^Instant]。
`LocalDateTime`, `OffsetDateTime`, `ZonedDateTime` などでもよいのですが、
それらだと時差やタイムゾーンなどを考える必要があります。
Entity には最もシンプルな型である `Instant` で持っておいて、それを使う所で必要な型に変換する方が、多くの場合は適切です。

[^Instant]: [`Date`](https://docs.oracle.com/javase/jp/8/docs/api/java/util/Date.html) クラスはもう古いです。

```Kotlin:日時型のプロパティを持つ Entity
@Entity
data class DateTimeEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val dateTime: Instant,
)
```

#### 型コンバーター

Entity に日時型を持たせただけでは、それをどのような型・値で DB に保存すればよいか決定できないため、ビルドエラーになります。
日時型と `YYYY-MM-DD HH:MM:SS` 形式の `String` 型とを相互変換するコンバーターを実装する必要があります。
　→[Room を使用して複雑なデータを参照する  \|  Android デベロッパー  |  Android Developers](https://developer.android.com/training/data-storage/room/referencing-data?hl=ja)

```Kotlin:型コンバーター
object DateTimeConverter {
    @TypeConverter
    fun stringToInstant(value: String): Instant =
        formatter.parse(value, Instant::from)
    @TypeConverter
    fun instantToString(value: Instant): String =
        formatter.format(value)
    
    private val formatter: DateTimeFormatter =
        DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss") // このままでは問題がある。詳細は後述。
}
```

実装した型コンバーターは `@TypeConverters` アノテーションを用いて、その変換を必要とする Entity を持つ `RoomDatabase` 派生クラスにセットします。

```Kotlin:型コンバーターを RoomDatabase 派生クラスにセットする
@Database(
    // ...
    entities = [
        DateTimeEntity::class,
    ],
)
@TypeConverters(
    DateTimeConverter::class,
)
abstract class DateTimeDatabase : RoomDatabase() {
    // ...
}
```

#### DAO のクエリメソッドと型コンバーター

DAO のクエリメソッドでは Java/Kotlin で定義された型の引数を SQL クエリで使うことができます。

```Kotlin
@Dao
interface DateTimeDao {
    @Query("SELECT count(*) FROM DateTimeEntity WHERE DateTimeEntity.dateTime > :dateTime")
    suspend fun countDateTimeEntitiesAfter(dateTime: Instant): Int
}
```

このとき引数は前述の型コンバーターによって変換してから SQL 内で使用されます。
今回の例だと `DateTimeEntity.dateTime > :dateTime` の比較は TEXT 型に変換してから行われます。


## タイムゾーン

`datetime` 関数の返値が表す日時のタイムゾーンは UTC（協定世界時）です。
ローカル DB に保存する値も UTC にしましょう。
UTC を JST（日本標準時）に変換するには、9時間を足します。
UTC で 0 時であれば、JST では 9 時です。

### `DateTimeFormatter` にタイムゾーンを指定する

日時型と `String` 型との相互変換に [`DateTimeFormatter` クラス](https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/DateTimeFormatter.html#patterns) を使用している場合、 [`withZone` メンバ関数](https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/DateTimeFormatter.html#withZone-java.time.ZoneId-)を使ってタイムゾーンを指定できます。

```Kotlin:DateTimeFormatter にタイムゾーンを指定する
    private val formatter: DateTimeFormatter =
        DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss") // このままでは問題がある。詳細は後述。
            .withZone(ZoneOffset.UTC) // タイムゾーンを UTC にする。
```


## 日時の有効範囲

`datetime` 関数は 0 年未満に対しては負号で始まる値を返します。
また 10,000 年以上に対しては `null` を返します。
```SQL
SELECT datetime('0000-01-01 00:00:00', '-1 seconds'); -- > -001-12-31 23:59:59
SELECT datetime('0000-01-01 00:00:00');               -- > 0000-01-01 00:00:00
SELECT datetime('9999-12-31 23:59:59');               -- > 9999-12-31 23:59:59
SELECT datetime('9999-12-31 23:59:59', '+1 seconds'); -- > null
```

SQL 上での日時の比較は TEXT 型として行われるため、
これらの値との比較は正しく行われないことになります。
ですのでこれらの値は SQL より前の段階で除く必要があります。

### `DateTimeFormatter` で有効範囲外の日時を除く

Room を使う場合は、型コンバーターで日時を `String` 型に変換するときに、日時が有効範囲外であれば例外をスローするようにするのがよいでしょう。

[`DateTimeFormatter.ofPattern` メンバ関数](https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/DateTimeFormatter.html#ofPattern-java.lang.String-)で生成した `DateTimeFormatter` では有効範囲外でもエラーになりません。
型コンバーター内で、日時型から `String` 型に変換する前にチェックするという手もありますが、
`DateTimeFormatter` で変換する際にエラーになる方が美しいですよね。
[`DateTimeFormatterBuilder` クラス](https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/DateTimeFormatterBuilder.html)を使えば `DateTimeFormatter.ofPattern` よりも細かい指定ができ、年が 4 桁を超えたときや負のときに例外がスローされるようにできます。
（`DateTimeFormatter.ofPattern` も内部で `DateTkmeFormatterBuilder` を使っているようです。）

```Kotlin:DateTimeFormatterBuilder で DateTimeFormatter を生成する
    private val formatter: DateTimeFormatter =
    // DateTimeFormatter.ofPattern で生成したものだと
    // 0 年未満や 10,000 年以上でもエラーにならないため、
        // DateTimeFormatterBuilder で生成する。
        DateTimeFormatterBuilder()
            .appendValue(ChronoField.YEAR, 4, 4, SignStyle.NOT_NEGATIVE)
            // ^ 4 桁を超える場合や負の場合はエラーとする。
            .appendLiteral('-')
            .appendValue(ChronoField.MONTH_OF_YEAR, 2)
            .appendLiteral('-')
            .appendValue(ChronoField.DAY_OF_MONTH, 2)
            .appendLiteral(' ')
            .appendValue(ChronoField.HOUR_OF_DAY, 2)
            .appendLiteral(':')
            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
            .appendLiteral(':')
            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
            .toFormatter()
            .withZone(ZoneOffset.UTC) // タイムゾーンを UTC にする。
}
```


## 精度

`YYYY-MM-DD HH:MM:SS` 形式の精度は秒です。
`Instant` の精度はナノ秒[^Dateの精度]ですので、`YYYY-MM-DD HH:MM:SS` 形式に変換して DB に保存すると精度が落ちることになります。

[^Dateの精度]: `Date` の精度はミリ秒です。

ちなみに、日時の丸めは端数切り捨てにすべきです。
そうでないと、秒が切り上がった場合に、分が、時が、日が、月が、年が切り上がることがあり、影響が非常に大きくなります。
（日が変わったらこれをする、月が変わったらそれをする、年が変わったらあれをする、とかありますよね。丸め程度でそんなことが起こると、対処が大変です。）
`DateTimeFormatter` は切り捨てになっているようです。（API ドキュメントに記述は見つけられませんでしたが。）
以下では切り捨てを前提に話を進めます。

さて、`YYYY-MM-DD HH:MM:SS` 形式で DB に保存された日時やそこから取り出した Entity が持つ日時は秒単位に丸められています。
SQL で日時を比較するときや、取り出した Entity が持つ日時との比較を行うときには、注意が必要です。

### 注意1: 元は異なる日時が同じ日時になる

元の日時が `2000-01-01 00:00:00.1` であったレコード A と、
元の日時が `2000-01-01 00:00:00.2` であったレコード B を考えます。
元の日時は A < B ですが、DB に保存される日時はいずれも `2000-01-01 00:00:00` になります。

A より後のレコードを取得するとき、
元の日時で考えると B も結果に含まれますが、
実際に処理すると B は結果に含まれません。

また B と同じもしくはより後のレコードを取得するとき、
元の日時で考えると A は結果に含まれませんが、
実際に処理すると A も結果に含まれます。

### 注意2: 日時が前になる

日時が `2000-01-01 00:00:00.1` である Entity のインスタンス A を生成して DB に保存することを考えます。
DB には `2000-01-01 00:00:00` として保存されます。

DB から全てのインスタンスを取り出し、そこから A が持つ日時より前のものを絞り込むとき、
日時の丸めがなければ A と同じ主キーを持つ Entity は含まれませんが、
実際には丸めがあるため A と同じ主キーを持つ Entity も結果に含まれます。


## まとめ

日時を Android のローカル DB に保存する場合は、
- `YYYY-MM-DD HH:MM:SS` 形式の TEXT 型にしましょう。
- 保存する日時のタイムゾーンを UTC にしましょう。
- 有効範囲外（0 年未満、10,000 年以上）を除外しましょう。
- 精度が秒であることに注意しましょう。

```Kotlin:型コンバーターの実装（完全版）
import androidx.room.TypeConverter
import java.time.DateTimeException
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.SignStyle
import java.time.temporal.ChronoField

/**
 * Room で日時を扱うためのコンバーター。
 *
 * Room が使用する SQLite には日時型がない（[Datatypes In SQLite](https://www.sqlite.org/datatype3.html)）ため、
 * SQLite の `datetime` 関数（[Date And Time Functions](https://www.sqlite.org/lang_datefunc.html)）と同じ
 * `YYYY-MM-DD HH:MM:SS` 形式の文字列として扱う。
 *
 * 文字列での日時の精度が秒であることに注意。
 */
object DateTimeConverter {
    /**
     * [String] を [Instant] に変換する。
     */
    @TypeConverter
    fun stringToInstant(value: String): Instant =
        formatter.parse(value, Instant::from)

    /**
     * [Instant] を [String] に変換する。
     *
     * 秒未満の値は切り捨てられる。
     *
     * @throws DateTimeException 0 年未満や 10,000 年以上の場合。
     */
    @TypeConverter
    fun instantToString(value: Instant): String =
        formatter.format(value)

    /**
     * `YYYY-MM-DD HH:MM:SS` 形式の日時フォーマッター。
     *
     * 0 年未満や 10,000 年以上はエラーになる。
     */
    private val formatter: DateTimeFormatter =
    // DateTimeFormatter.ofPattern で生成したものだと
    // 0 年未満や 10,000 年以上でもエラーにならないため、
        // DateTimeFormatterBuilder で生成する。
        DateTimeFormatterBuilder()
            .appendValue(ChronoField.YEAR, 4, 4, SignStyle.NOT_NEGATIVE)
            // ^ 4 桁を超える場合や負の場合はエラーとする。
            .appendLiteral('-')
            .appendValue(ChronoField.MONTH_OF_YEAR, 2)
            .appendLiteral('-')
            .appendValue(ChronoField.DAY_OF_MONTH, 2)
            .appendLiteral(' ')
            .appendValue(ChronoField.HOUR_OF_DAY, 2)
            .appendLiteral(':')
            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
            .appendLiteral(':')
            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
            .toFormatter()
            .withZone(ZoneOffset.UTC) // タイムゾーンを UTC にする。
}
```

## コード全文

動作確認のため実装したコード全文を GitHub に上げました。
JUnit テストで動作確認しました。

https://github.com/sdkei/DateTimeInLocalDatabaseOfAndroid

／以上