1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-10-09

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
@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 派生クラスにセットします。

型コンバーターを 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 メンバ関数を使ってタイムゾーンを指定できます。

DateTimeFormatter にタイムゾーンを指定する
    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 を使っているようです。)

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 の精度はナノ秒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 テストで動作確認しました。

/以上

  1. Date クラスはもう古いです。

  2. Date の精度はミリ秒です。

1
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?