SQLite と日時型
Android 標準のローカルデータベースは SQLite です。
Room ライブラリを使った場合も内部では SQLite が使用されます。
SQLite には日時型がありません。
→Datatypes In SQLite
日時を保存するには、SQLite で使えるいずれかの型に変換してやる必要があります。
SQLite の datetime
関数は YYYY-MM-DD HH:MM:SS
形式の TEXT 型の値を返します。
→Date And Time Functions
SQL で datetime
関数との比較などを行えるようにするため、
ローカル DB に保存するのはこれと同じ形式にするのがよいでしょう。
Room と日時型
Room の Entity に日時型のプロパティを持たせることを考えます。
日時型としては Instant
クラスを使うのがよいでしょう1。
LocalDateTime
, OffsetDateTime
, ZonedDateTime
などでもよいのですが、
それらだと時差やタイムゾーンなどを考える必要があります。
Entity には最もシンプルな型である Instant
で持っておいて、それを使う所で必要な型に変換する方が、多くの場合は適切です。
@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
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
派生クラスにセットします。
@Database(
// ...
entities = [
DateTimeEntity::class,
],
)
@TypeConverters(
DateTimeConverter::class,
)
abstract class DateTimeDatabase : RoomDatabase() {
// ...
}
DAO のクエリメソッドと型コンバーター
DAO のクエリメソッドでは Java/Kotlin で定義された型の引数を SQL クエリで使うことができます。
@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
クラス を使用している場合、 withZone
メンバ関数を使ってタイムゾーンを指定できます。
private val formatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss") // このままでは問題がある。詳細は後述。
.withZone(ZoneOffset.UTC) // タイムゾーンを UTC にする。
日時の有効範囲
datetime
関数は 0 年未満に対しては負号で始まる値を返します。
また 10,000 年以上に対しては null
を返します。
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
メンバ関数で生成した DateTimeFormatter
では有効範囲外でもエラーになりません。
型コンバーター内で、日時型から String
型に変換する前にチェックするという手もありますが、
DateTimeFormatter
で変換する際にエラーになる方が美しいですよね。
DateTimeFormatterBuilder
クラスを使えば DateTimeFormatter.ofPattern
よりも細かい指定ができ、年が 4 桁を超えたときや負のときに例外がスローされるようにできます。
(DateTimeFormatter.ofPattern
も内部で DateTkmeFormatterBuilder
を使っているようです。)
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
の精度はナノ秒2ですので、YYYY-MM-DD HH:MM:SS
形式に変換して DB に保存すると精度が落ちることになります。
ちなみに、日時の丸めは端数切り捨てにすべきです。
そうでないと、秒が切り上がった場合に、分が、時が、日が、月が、年が切り上がることがあり、影響が非常に大きくなります。
(日が変わったらこれをする、月が変わったらそれをする、年が変わったらあれをする、とかありますよね。丸め程度でそんなことが起こると、対処が大変です。)
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 年以上)を除外しましょう。
- 精度が秒であることに注意しましょう。
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 テストで動作確認しました。
/以上