UstadMobile / door

Kotlin Multiplatform (Android, JVM, JS) offline-first data layer built on Room
Apache License 2.0
165 stars 1 forks source link
android offline-first room-database

Door

Release JS JVM JS

Door is a Kotlin Symbol Processor that builds on Room to generate a complete full-stack offline-first database system, including HTTP REST server endpoints, local data sources and client repositories. Door supports both pull and push operations.

Platforms supported:

No support for iOS/Native (yet - pull request would be welcome. Happy to help support anyone who would like to work on this).

Return types supported:

Example Entity:

@Entity
@Triggers()
class Widget() {
    @PrimaryKey(autoIncrement = true)
    var widgetUid: Long = 0

    var widgetPrice: Float = 0f

    var widgetName: String? = null

    @ReplicationEtag
    @ReplicationLastModified
    var widgetLastModified: Long = 0
}

Example DAO:

expect class MyDao {

    @HttpAccessible
    suspend fun findAllWidgets(): List<Widget>

}

REST Server:

Door will automatically generate a KTOR REST endpoint. You can add it to your KTOR server using doorRoute.

val myDb = DatabaseBuilder.builder(DbClass::class, "jdbc:sqlite:file.db")
               .build()
route("mydatasource") {
    doorRoute(myDb)
}

Now an HTTP get to /mydatasource/MyDao/findAllWidgets will return the list.

Android Client:

val myDb = DatabaseBuilder.builder(DbClass::class, "MyDb", context)
          .build()

val repository = myDb.asRepository("http://mserver.com/mydatasource/")

val widgets = repository.myDao.findAllWidgets()

Getting started

Very basic example repo: https://github.com/UstadMobile/door-example

  1. Add Gradle dependencies:

    
    //Add jitpack repository if you don't already have it
    buildscript {
    repositories {
        mavenCentral()
        google()
    
        maven {
           url 'https://jitpack.io/'
        }
    }
    }

plugins { id "org.jetbrains.kotlin.multiplatform" id "com.android.library"

//Add Kotlin Symbol Processing Plugin
id "com.google.devtools.ksp"

}

kotlin { android { }

jvm { }

js { }

sourceSets {
    commonMain {
        dependencies {
            //Add Door itself
            implementation "com.github.UstadMobile.door:door-runtime:$version_door"
            implementation "com.github.UstadMobile.door:room-annotations:$version_door"
        }
    }

    androidMain {
        dependencies {
            //Add Room dependencies for Android
            implementation "androidx.room:room-runtime:$version_android_room"
            implementation "androidx.room:room-ktx:$version_android_room"
            implementation "androidx.paging:paging-runtime:$version_androidx_paging"
        }
    }
}

}

dependencies { //Add the Door and Room Kotlin Symbol Processors for applicable platforms kspJvm "com.github.UstadMobile.door:door-compiler:$version_door" kspJs "com.github.UstadMobile.door:door-compiler:$version_door" kspAndroid "com.github.UstadMobile.door:door-compiler:$version_door" kspAndroid "androidx.room:room-compiler:$version_android_room" }


On your Android application module (e.g. the one building the APK), you **must** exclude Door's room-annotations to avoid a
duplicate class error. This is required due to a Kotlin/JS bug as per this [README](room-annotations/).

configurations.all { exclude group: "com.github.UstadMobile.door", module: "room-annotations" }


2. Now create your database, DAOs, and entities in Kotlin multiplatform common code:

Database:

import com.ustadmobile.door.room.RoomDatabase import com.ustadmobile.door.annotations.DoorDatabase

@DoorDatabase(version = 1, entities = [ MyEntity::class, AnotherEntity::class]) expect abstract class MyDatabase: RoomDatabase {

 val myEntityDao: MyEntityDao

 val anotherEntityDao: AnotherEntityDao 

}


DAO:

import com.ustadmobile.door.annotations.DoorDao

@DoorDao expect abstract class MyEntityDao {

 @Query("SELECT * FROM MyEntity WHERE id = :id")
 fun myQuery(id: Int): MyEntity?

}


Entity:

import androidx.room.Entity import androidx.room.PrimaryKey

@Entity class MyEntity() { @PrimaryKey(autoGenerate = true) var id: Long = 0

var name: String? = null

var rewardsCardNumber: Int = 0

}


3. Create and use the database in your code. Database creation is platform-specific, so it's best to use multiplatform dependency injection
(such as KodeIN-DI) or create your own expect-actual function.

JVM:

//Option 1: Use an SQLite JDBC URL val sqliteDatabase = DatabaseBuilder.databaseBuilder(MyDatabase::class, "jdbc:sqlite:path/to/file.sqlite").build()

//Option 2: Use a Postgres JDBC URL val postgresDatabase = DatabaseBuilder.databaseBuilder(MyDatabase::class, "jdbc:postgres:///mydbname", dbUsername = "pguser", dbPassword = "secret").build()

//Option 3: Use a JNDI DataSource (e.g. using within an application server etc) val jndiDatabase = DatabaseBuilder.databaseBuilder(MyDatabase::class, "java:/comp/env/jdbc/myDB")

Android:

val myDatabase = DatabaseBuilder.databaseBuilder(context, MyDatabase::class, "mydatabase").build()


Javascript 

//Note: build() is a suspended function on Javascript //MyDatabase2JsImplementations is a generated class that needs to be given as an argument //sqliteJsWorkerUrl should be a URL to the SQLite.js worker - see https://sql.js.org/#/?id=downloadingusing val builderOptions = DatabaseBuilderOptions( MyDatabase::class, MyDatabase2JsImplementations, "sqlite:my_indexdb_name",sqliteJsWorkerUrl) val myDatabase = DatabaseBuilder.databaseBuilder(builderOptions).build()


Limitations:
* Because we are using expect/actual, no function body can be added (better to use extension functions).
* No support for choosing entity constructors. Door requires and will always choose the no args constructor.
* No support for Room @Relation annotation or Multimap return types
* No support for TypeConverter

## Debugging

Use the procedure as per the [KSP documentation](https://github.com/google/ksp/blob/main/DEVELOPMENT.md#debug-a-processor-andor-ksp)
e.g.
1) Kill the Gradle daemon and Kotlin compile daemon

$ ./gradlew --stop; pkill -f KotlinCompileDaemon $ ./gradlew door-testdb:jvmJar --rerun-tasks -Dkotlin.daemon.jvm.options="-Xdebug,-Xrunjdwp:transport=dt_socket\,address=8765\,server=y\,suspend=n"

2) Attach the IDE's remote debugger to the port as per the argument (e.g. 8765 as above)

## Automatic REST endpoint generation

Door eliminates the need to manually create boilerplate REST endpoints. If entities are marked with @ReplicateEntity, then Door 
automatically uses the @Etag field as an ETag over http. Just annotate the query as HttpAccessible

@HttpAccessible susped fun findEntityByPrimaryKey(primaryKey: Long): MyEntity?


When you use the automatically generated repository, Door will automatically make a http request. If there is changed
Data, door will insert it (the same as if the data was received via replication). Results that return a list will use a
hashed etag based on all etags in the list returned.

## Postgres/SQLite query differentiation

Most of the time SQL that works on SQLite works on Postgres, and vice-versa. But not always. Door provides a few 
workarounds.

Option 1: Define a different query for postgres (both queries must have the same named parameters).

@Query(""" REPLACE INTO TableName(col1, col2) SELECT 1 as col1, 2 as col2 WHERE NOT EXISTS (...) """) @PostgresQuery(""" INSERT INTO TableName1(col1, col2) SELECT 1 as col1, 2 as col2 ON CONFLICT(col1) DO UPDATE SET col2 = EXCLUDED.col2 """)


Option 2: Use comment hacks:

@Query(""" -- Replace into will be turned into INSERT INTO REPLACE INTO TableName(col1, col2) SELECT 1 as col1, 2 as col2 --notpsql -- Anything here will NOT run on Postgres WHERE NOT EXISTS(...) --endnotpsql

/psql -- Anything here will ONLY run on Postgres ON CONFLICT(col1) DO UPDATE SET col2 = EXCLUDED.col2
/ """)



## Android permissions:

* android.permission.ACCESS_NETWORK_STATE - Used to automatically turn replication on and off when a device is 
  connected and disconnected.

## Modules

* [app-testdb](app-testdb/) Contains a test React/JS app. Used to run manual tests that don't seem to work in automated
JS tests.
* [room-annotations](room-annotations/) Contains androidx annotations identical to those used in room,
used to compile on non-Android targets.
* [door-compiler](door-compiler/) Contains the actual annotation processor based on Kotlin Poet
* [door-runtime](door-runtime/) The main runtime module - contains classes and functions used by generated
code
* [door-testdb](door-testdb/) Contains a few test databases that are used for unit and integration testing. These 
databases are compiled by the annotation processor, so tests can verify functionality.

## Known issues

* door-testdb:jsBrowserTest on a limited Internet connection may fail. The test has to download SQLite.js
from the Internet due to issues with asset loading. It can be skipped if building locally.

* When updating dependencies: Wrong kotlin lock upgrade message: use kotlinUpgradeYarnLock not kotlinUpgradePackageLock as per  https://youtrack.jetbrains.com/issue/KT-66714