Лучший SQL Builder – используем jOOQ на Android

  • Tutorial

Лучший SQL Builder. Используем jOOQ на Android


Введение


При разработке Android-приложений вполне естественным считается использовать SQLite базу данных в качестве основного хранилища. Обычно, базы данных на мобильных устройствах имеют весьма простенькие схемы и состоят из 10-15 таблиц. Для подобных случаев подходит почти любой SQL Builder, ORM, и даже голый SQLite API.


Но, увы, не всем разработчикам везет, и порой на нашу долю выпадает описывать большие модели данных, использовать хранимые процедуры, настраивать работу с кастомными типами данных или писать 10 INNER JOIN в запросе за очень толстой сущностью. Так не повезло и вашему покорному слуге, из чего и появился материал для данной статьи. Что же, суровые времена требуют суровых мер. Итак, накатываем jOOQ на Android.


Все бы хорошо, но


Но есть два факта, с которыми нужно будет совладать. Первый из них подстерегает нас на самом начале работы с jOOQ: на этапе идеологическом. Для того, чтобы инициировать процесс кодогенерации, нужно, собственно, заиметь базу данных, к которой jooq plugin подключится. Данная проблема решается легко, создаем template-проект с описанием gradle task для генерации, после чего создаем БД локально, прописываем в конфигах пути, запускаем плагин и копируем полученные исходники к себе в проект.


Далее, допустим мы сгенерировали все необходимые классы. Просто так скопировать их в Android-проект мы не сможем – будут требоваться дополнительные зависимости, первая из которых – на javax аннотации. Варианта два, оба банальные. Либо добавляем библиотеку (org.glassfish:javax.annotation), либо – используем замечательный инструмент – find & replace in scope.


И вот казалось бы, все хорошо, все предварительные настройки сделаны, классы скопированы и импортированы в проект. Возможно вам даже удастся запустить приложение, и есть шанс, что оно заработает. Если вы обязаны поддерживать Android API Level < 24 – не ведитесь, на это наш путь еще не заканчивается. Дело заключается в том, что jOOQ на текущий момент в open-source версии во многом использует Java 8, которая, как известно, с Android дружит весьма условно. Эта проблема также решается двумя вариантами: либо покупаем jOOQ, пишем в саппорт и слезно выпрашиваем версию на Java 6 или Java 7 (у них есть, судя по статьям в сети), либо же, если у вас, как и у меня, нет жесткой необходимости обладать всеми последними нововведениями библиотеки, равно как и желания платить, то есть второй путь. jOOQ начал переходить на Java 8 не так давно. Последняя из версий до миграции является 3.6.0, что значит, что мы можем использовать генератор с параметром groovy version = '3.6.0' и поддерживать старые версии устройств.


И последнее, что ждет энтузиастов, пошедших по этой тропинке отчаяния. В Android в принципе нет JDBC, что значит, что пришло время скрестив пальцы искать 3rd-party solutions. К счастью, подобная библиотека есть – SQLDroid.


Все. Основные этапы и действия на них бегло расписаны. Теперь перейдем к коду, тут все в целом довольно логично, но, дабы сократить ваше время, приведу примеры из собственного проекта.


Кодогенерация


Настройка jOOQ плагина будет выглядеть следующим образом:


buildScript {
    repositories {
        mavenCentral()
    }

    dependencies {
        classpath "nu.studer:gradle-jooq-plugin:$jooq_plugin_version"
    }
}

apply plugin: 'nu.studer.jooq'

dependencies {
    jooqRuntime "org.xerial:sqlite-jdbc:$xerial_version"
}

jooq {
    version = '3.6.0'
    edition = 'OSS'

    dev(sourceSets.main) {
        jdbc {
            driver = 'org.sqlite.JDBC'
            url = 'jdbc:sqlite:/Path/To/Database/database.db3'
        }

        generator {
            name = 'org.jooq.util.DefaultGenerator'
            strategy {
                name = 'org.jooq.util.DefaultGeneratorStrategy'
            }
            database {
                name = 'org.jooq.util.sqlite.SQLiteDatabase'
            }
            generate {
                relations = true
                deprecated = false
                records = true
                immutablePojos = true
                fluentSetters = true
            }
            target {
                packageName = 'com.example.mypackage.data.database'
            }
        }
    }
}

Android


Необходимые зависимости:


implementation "org.jooq:jooq:$jooq_version"
implementation "org.sqldroid:sqldroid:$sqldroid_version"
implementation "org.glassfish:javax.annotation:$javax_annotations_version"

А теперь исходники класса-обертки, для работы с jOOQ через SQLiteOpenHelper. В целом, без него можно было бы обойтись, но так куда удобнее (на мой взгляд), чтобы благополучно пользоваться и одним, и вторым API.


class DatabaseAdapter(private val context: Context)
    : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {

    companion object {

        private const val DATABASE_NAME     = "database"
        private const val DATABASE_VERSION  = 1

        @JvmStatic private val OPEN_OPTIONS = mapOf(
                "cache" to "shared",
                "journal_mode" to "WAL",
                "synchronous" to "ON",
                "foreign_keys" to "ON")
    }

    val connectionLock: ReentrantLock = ReentrantLock(true)
    val configuration: Configuration by lazy(mode = LazyThreadSafetyMode.NONE) {
        connectionLock.withLock {
            // ensure the database exists,
            // all upgrades are performed,
            // and connection is ready to be set
            val database = context.openOrCreateDatabase(
                DATABASE_NAME, 
                Context.MODE_PRIVATE, 
                null)
            if (database.isOpen) {
                database.close()
            }

            // register SQLDroid driver to be used for establishing connections
            // with our database
            DriverManager.registerDriver(
                Class.forName("org.sqldroid.SQLDroidDriver")
                    .newInstance() as Driver)

            DefaultConfiguration()
                    .set(SQLiteSource(
                        context, 
                        OPEN_OPTIONS, 
                        "database", 
                        arrayOf("databases")))
                    .set(SQLDialect.SQLITE)
        }
    }

    override fun onCreate(db: SQLiteDatabase) {
        // acquire monitor until the database connection is created
        // this is important as otherwise transactions might be tryingg to run
        // concurrently that will lead to crashes
        connectionLock.withLock {
            // TODO: Create tables
        }
    }

    override fun onOpen(db: SQLiteDatabase) {
        // acquire monitor until the database connection is established
        // this is important as otherwise transactions might be tryingg to run
        // concurrently that will lead to crashes
        connectionLock.withLock {
            super.onOpen(db)
        }
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        // acquire monitor until the database is upgraded
        // this is important as otherwise transactions might be tryingg to run
        // concurrently that will lead to crashes
        connectionLock.withLock {

        }
    }

    infix inline fun <reified T> transaction(noinline f: (Configuration) -> T): Observable<T>
            = Observable.create { emitter ->
        val tryResult = Try {
            connectionLock.withLock {
                DSL.using(configuration).transactionResult(f)
            }
        }

        when (tryResult) {
            is Try.Success -> {
                emitter.onNext(tryResult.value)
                emitter.onComplete()
            }
            is Try.Failure -> {
                emitter.onError(tryResult.exception)
            }
        }
    }

    fun invalidate() {
        connectionLock.withLock {
            // TODO: Drop tables, vacuum and create tables
        }
    }

    private class SQLiteSource(val context: Context,
                               val options: Map<String, String>,
                               val database: String,
                               val fragments: Array<out String>): DroidDataSource() {

        override fun getConnection(): Connection
                = openConnection(options)

        private fun openConnection(options: Map<String, String> = emptyMap()): Connection {
            return DriverManager.getConnection(StringBuilder().apply {
                append("jdbc:sqldroid:")
                append(context.applicationInfo.dataDir)
                append("/")
                append(buildFragments(fragments))
                append(database)
                append("?")
                append(buildOptions(options))
            }.toString())
        }

        private fun buildFragments(fragments: Array<out String>)
                = when (fragments.isEmpty()) {
            true  -> ""
            false -> "${fragments.joinToString("/")}/"
        }

        private fun buildOptions(options: Map<String, String>)
                = options.mapTo(mutableListOf<String>()) { entry ->
            "${entry.key}=${entry.value}"
        }
                .joinToString(separator = "&")
    }
}

UPD: добавил в lazy-инициализацию mode = LazyThreadSafetyMode.NONE, спасибо konstantin_berkow


Вместо заключения


Как оказалось, настройка jOOQ в Android – не такой уж и сложный процесс. Достаточно проделать его один раз, а далее можно смело заниматься копипастом из старых проектов.


И небольшой бонус, который дает jOOQ тем, кто его использует. Как видно из примера, пи открытии подключения используется cached mode. В чем же цимес? Android SDK SQLite API не предоставляет возможности работать с БД в данном режиме, сильно ограничивая нас в организации межпроцессного взаимодействия в приложениях. Теперь же – можно смело использовать данный режим, что уже само по себе может послужить причиной перехода на этот замечательный фреймворк.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Стоит ли овчинка выделки?
  • +13
  • 1,6k
  • 2
Поделиться публикацией
Комментарии 2
    0
    Экономия на спичках, но мне кажется можно lazy чуть упростить — выставить ему модификатор NONE, так как вся работа и так под замком.
        val configuration: Configuration by lazy(mode = LazyThreadSafetyMode.NONE) {
            connectionLock.withLock {
            // ...
            }
        }
    
      0
      Да, действительно, спасибо)

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое