Multiplatform Persistence

Multiplatform Persistence

A lot of client code is already multiplatform - the tools underneath client backends like SQLite, protobufs, and HTTP are themselves platform agnostic. Codebases for Android and iOS already look similar on top of these tools until exposed to platform specific interfaces. Realistically how much could be shared across multiple platforms without affecting any codebase significantly?

This talk will go over SQLDelight 1.0 - a recent release which has enabled multiplatform SQLite development using Kotlin Native, as well as how to architect your app’s data layer to be shared across platforms. It will focus on how SQLDelight can be best used from both Android and iOS while feeling natural to Kotlin and Swift users alike, and go over features introduced in the release that affect all platforms. Finally we will explore the future of multiplatform including other components of your app which are good or bad candidates for making platform agnostic and what future releases of SQLDelight will include.

5fe8f40633300a70ea088a594cb33031?s=128

Alec Strong

August 27, 2018
Tweet

Transcript

  1. Multiplatform
 Persistence

  2. None
  3. message Customer { required int32 id = 1; required string

    name = 2; required string photoUrl = 3; } message Payment { required Money amount = 1; required int32 sender = 2; required int32 recipient = 3; } protos
  4. message Customer { required int32 id = 1; required string

    name = 2; required string photoUrl = 3; } message Payment { required Money amount = 1; required int32 sender = 2; required int32 recipient = 3; } protos
  5. public interface AppService { @POST("/2.0/cash/sync-entities") // Observable<SyncEntitiesResponse> syncEntities( @Body SyncEntitiesRequest

    request); }
  6. public interface AppService { @POST("/2.0/cash/sync-entities") // Observable<SyncEntitiesResponse> syncEntities( @Body SyncEntitiesRequest

    request); } protos api
  7. CREATE TABLE contact ( lookup_key TEXT PRIMARY KEY, display_name TEXT

    ); CREATE TABLE alias ( hashed_alias TEXT NOT NULL PRIMARY KEY, email TEXT, sms TEXT, customer_id TEXT REFERENCES customer ); CREATE TABLE customer ( customer_id TEXT PRIMARY KEY, photo_url TEXT, customer_display_name TEXT );
  8. CREATE TABLE contact ( lookup_key TEXT PRIMARY KEY, display_name TEXT

    ); CREATE TABLE alias ( hashed_alias TEXT NOT NULL PRIMARY KEY, email TEXT, sms TEXT, customer_id TEXT REFERENCES customer ); CREATE TABLE customer ( customer_id TEXT PRIMARY KEY, photo_url TEXT, customer_display_name TEXT ); db protos api
  9. backend db protos api

  10. backend presenters db protos api

  11. backend presenters viewmodel + viewevent db protos api

  12. backend presenters viewmodel + viewevent app db protos api

  13. db protos api presenters viewmodel + viewevent app backend val

    presenter = ActivityPresenter()
  14. val presenter = ActivityPresenter() db protos api presenters viewmodel +

    viewevent app backend entitySyncer.triggerSync( foreground = false, force = false ).subscribe() appService.syncEntities(request) @POST("/2.0/cash/sync-entities") // Observable<SyncEntitiesResponse> syncEntities( @Body SyncEntitiesRequest request); paymentQueries.insertPayment( payment.token!!, payment.sender_id, payment.recipient_id, payment.amount ) insertPayment: INSERT OR REPLACE INTO payment (token, sender_id, recipient_id, amount) VALUES (?, ?, ?, ?); fun renderedPayment( paymentToken: String ): Observable<RenderedPayment> entityManager .renderedPayment(token) .map(this::createViewModel) .subscribe(view) presenter.viewModel() .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::renderViewModel)
  15. db protos api presenters viewmodel + viewevent app backend

  16. interface InstrumentManager { @CheckResult fun withToken( token: String ): Observable<Instrument>

    } backend
  17. internal class RealInstrumentManager @Inject constructor( private val appService: AppService, private

    val queryWrapper: QueryWrapper, @Io private val ioScheduler: Scheduler ) : InstrumentManager backend
  18. internal class RealInstrumentManager @Inject constructor( private val appService: AppService, private

    val queryWrapper: QueryWrapper, @Io private val ioScheduler: Scheduler ) : InstrumentManager backend
  19. internal class RealInstrumentManager @Inject constructor( private val appService: AppService, private

    val queryWrapper: QueryWrapper, @Io private val ioScheduler: Scheduler ) : InstrumentManager backend
  20. class RealInstrumentManagerTest { @get:Rule private val temporaryDatabase = TemporaryDatabase() @Before

    fun before() { val appService = FakeAppService() val queryWrapper = temporaryDatabase.queryWrapper instrumentManager = RealInstrumentManager( appService = appService, queryWrapper = queryWrapper, ioScheduler = Schedulers.trampoline() ) } } backend
  21. db protos api presenters viewmodel + viewevent app backend

  22. db protos api presenters viewmodel + viewevent app backend

  23. None
  24. None
  25. None
  26. None
  27. db backend api presenters viewmodel + viewevent app protos

  28. protos Wire protobufs

  29. db backend presenters viewmodel + viewevent app protos api

  30. api Retrofit Http protos RxJava

  31. backend presenters viewmodel + viewevent app protos api db

  32. db SQLite SqlDelight SqlBrite

  33. presenters viewmodel + viewevent app protos api backend db

  34. backend dagger db api RxJava

  35. viewmodel + viewevent app protos api db presenters backend

  36. presenters backend viewmodels analytics RxJava

  37. viewmodel + viewevent app api db protos presenters backend

  38. viewmodel + viewevent app api db protos presenters backend

  39. viewmodel + viewevent app api db protos presenters backend

  40. protos Wire protobufs

  41. protos Wire protobufs parcelable

  42. viewmodel + viewevent app api presenters db protos backend

  43. viewmodel + viewevent app api presenters protos backend db

  44. db SQLite SqlDelight SqlBrite

  45. db SqlDelight SqlBrite

  46. db SqlBrite SqlDelight

  47. db SqlBrite SqlDelight

  48. db SqlBrite SqlDelight

  49. SqlDelight 1.0

  50. SqlDelight 1.0 (alpha)

  51. Player.sq: CREATE TABLE player ( id INTEGER NOT NULL PRIMARY

    KEY, name TEXT NOT NULL, country TEXT NOT NULL ); build/Player.kt: interface Player { val id: Long val name: String val country: String data class Impl( override val id: Long, override val name: String, override val country: String ) : Player }
  52. Surface.kt: enum class Surface { OUTDOOR_HARD, INDOOR_HARD, GRASS, CLAY }

    Tournament.sq: CREATE TABLE tournament ( id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL, surface TEXT AS Surface NOT NULL, year INTEGER NOT NULL ); Tournament.sq: CREATE TABLE tournament ( id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL, surface TEXT AS Surface NOT NULL, year INTEGER NOT NULL );
  53. build/Tournament.sq: interface Tournament { val id: Long val name: String

    val surface: Surface val year: Long class Adapter( internal val surfaceAdapter: ColumnAdapter<Surface, String> ) data class Impl( override val id: Long, override val name: String, override val surface: Surface, override val year: Long ) : Tournament } build/Tournament.sq: interface Tournament { val id: Long val name: String val surface: Surface val year: Long class Adapter( internal val surfaceAdapter: ColumnAdapter<Surface, String> ) data class Impl( override val id: Long, override val name: String, override val surface: Surface, override val year: Long ) : Tournament } build/Tournament.sq: interface Tournament { val id: Long val name: String val surface: Surface val year: Long class Adapter( internal val surfaceAdapter: ColumnAdapter<Surface, String> ) data class Impl( override val id: Long, override val name: String, override val surface: Surface, override val year: Long ) : Tournament }
  54. com.squareup.sqldelight:runtime:1.0.0 interface ColumnAdapter<T : Any, S> { fun decode(databaseValue: S):

    T fun encode(value: T): S }
  55. com.squareup.sqldelight:runtime:1.0.0 class EnumColumnAdapter<T : Enum<T>> inline fun <reified T :

    Enum<T>> EnumColumnAdapter() : EnumColumnAdapter<T> { return EnumColumnAdapter(enumValues()) }
  56. src/YourCode.kt: val queryWrapper = QueryWrapper( database = database, tournamentAdapter =

    Tournament.Adapter( surfaceAdapter = EnumColumnAdapter()A )A ) src/YourCode.kt: val queryWrapper = QueryWrapper( database = database, tournamentAdapter = Tournament.Adapter( surfaceAdapter = EnumColumnAdapter()A )A )
  57. com.squareup.sqldelight:runtime:1.0.0 interface SqlDatabase { fun getConnection(): SqlDatabaseConnection fun close() }

  58. db app Tennis

  59. db app Tennis

  60. db app db:android

  61. db apply plugin: 'org.jetbrains.kotlin.platform.common' apply plugin: 'com.squareup.sqldelight'

  62. db expect class DatabaseFactory { fun create(): SqlDatabase }

  63. db app db:android

  64. apply plugin: 'com.android.library' apply plugin: 'kotlin-platform-android' dependencies { expectedBy project(':db')

    implementation “com.squareup.sqldelight:android-driver:1.0.0-alpha5” } db:android
  65. db:android actual class DatabaseFactory( private val context: Context ) {

    actual fun create(): SqlDatabase { return QueryWrapper.create(context, "tennis.db") } }
  66. db app db:android

  67. db fun QueryWrapper.Helper.create( databaseFactory: DatabaseFactory ): QueryWrapper { val queryWrapper

    = QueryWrapper( database = databaseFactory.create(), tournamentAdapter = Tournament.Adapter( surfaceAdapter = EnumColumnAdapter() ) ) }
  68. db app db:android

  69. val queryWrapper = QueryWrapper.create(DatabaseFactory(this)) app

  70. val queryWrapper = QueryWrapper.create(DatabaseFactory(this)) queryWrapper.playerQueries.insert( id = 1, name =

    "Novak Djokovic", country = "Serbia" ) app
  71. db app db:android

  72. db app db:android iosApp db:native

  73. db:native apply plugin: ‘kotlin-platform-native’ dependencies { expectedBy project(‘:db') implementation “co.touchlab.knarch:knarch:0.7-alpha4"

    implementation "com.squareup.sqldelight:sqldelightruntimeios" implementation "com.squareup.sqldelight:sqldelightmultiplatformdriverios" }
  74. actual class DatabaseFactory { actual fun create(): SqlDatabase { val

    context = DefaultSystemContext() val factory = IosNativeOpenHelperFactory(context) return QueryWrapper.create( name = "tennis.db", openHelperFactory = factory ) } } db:native
  75. db app db:android iosApp db:native

  76. iosApp let database = DbQueryWrapperHelper().create( databaseFactory: DbDatabaseFactory() )A

  77. let database = DbQueryWrapperHelper().create( databaseFactory: DbDatabaseFactory() )A database.playerQueries .insert( id:

    1, name: "Novak Djokovic", country: "Serbia" ) iosApp
  78. db app db:android iosApp db:native

  79. db val federer = 3L queryWrapper.playerQueries.insert( id = federer, name

    = "Roger Federer", country = "Switzerland" ) insert: INSERT INTO player (id, name, country) VALUES (?, ?, ?);
  80. db CREATE TABLE match ( tournament INTEGER NOT NULL REFERENCES

    tournament, player1 INTEGER NOT NULL REFERENCES player, player2 INTEGER NOT NULL REFERENCES player, winner INTEGER NOT NULL REFERENCES player, CHECK (winner == player1 OR winner == player2), CHECK (player1 <> player2), PRIMARY KEY (player1, player2, tournament) );
  81. db queryWrapper.tournamentQueries.insert( id = 2, name = "ATP World Tour

    Masters 1000 Miami", surface = OUTDOOR_HARD, year = 2017 ) queryWrapper.matchQueries.insert( tournament = 2, player1 = rafa, player2 = federer, winner = federer )
  82. db headToHead: SELECT player1.name AS player1Name, player2.name AS player2Name, count(nullif(match.winner

    = player1.id, 0)) AS player1Wins, count(nullif(match.winner = player2.id, 0)) AS player2Wins FROM match JOIN player AS player1 ON (player1.name = :firstPlayerName AND (player1.id = match.player1 OR player1.id = match.player2) ) JOIN player AS player2 ON (player2.name = :secondPlayerName AND (player2.id = match.player1 OR player2.id = match.player2) ) ;
  83. Match.sq: headToHead: SELECT ... db build/HeadToHead.kt: interface HeadToHead { val

    player1Name: String val player2Name: String val player1Wins: Long val player2Wins: Long }
  84. Match.sq: headToHead: SELECT ... db build/MatchQueries.kt: class MatchQueries { fun

    headToHead( firstPlayerName: String, secondPlayerName: String ): Query<HeadToHead> }
  85. Match.sq: headToHead: SELECT ... db build/MatchQueries.kt: class MatchQueries { fun

    <T : Any> headToHead( firstPlayerName: String, secondPlayerName: String, mapper: ( player1Name: String, player2Name: String, player1Wins: Long, player2Wins: Long ) -> T ): Query<T>
  86. com.squareup.sqldelight:runtime:1.0.0 open class Query<out RowType : Any> { fun addListener(listener:

    Listener) fun removeListener(listener: Listener) fun executeAsList(): List<RowType> fun executeAsOne(): RowType fun executeAsOneOrNull(): RowType? interface Listener { fun queryResultsChanged() } } com.squareup.sqldelight:runtime:1.0.0 open class Query<out RowType : Any> { fun addListener(listener: Listener) fun removeListener(listener: Listener) fun executeAsList(): List<RowType> fun executeAsOne(): RowType fun executeAsOneOrNull(): RowType? interface Listener { fun queryResultsChanged() } }
  87. db backend protos api presenters viewmodel + viewevent app interface

    InstrumentManager { @CheckResult fun withToken( token: String ): Observable<Instrument> }
  88. iosApp db:native db app db:android

  89. iosApp db:native db app db:android

  90. com.squareup.sqldelight:rxjava2-extensions:1.0.0 fun <T : Any> Query<T>.asObservable( scheduler: Scheduler = Schedulers.io()

    ) : Observable<Query<T>>
  91. com.squareup.sqldelight:rxjava2-extensions:1.0.0 fun <T : Any> Query<T>.asObservable( scheduler: Scheduler = Schedulers.io()

    ) : Observable<Query<T>> fun <T : Any> Observable<Query<T>>.mapToOne(): Observable<T> fun <T : Any> Observable<Query<T>>.mapToOneOrDefault( defaultValue: T ) : Observable<T> fun <T : Any> Observable<Query<T>>.mapToOptional() : Observable<Optional<T>> fun <T: Any> Observable<Query<T>>.mapToList(): Observable<List<T>> fun <T : Any> Observable<Query<T>>.mapToOneNonNull(): Observable<T>
  92. iosApp db:native db app db:android

  93. implementation “com.squareup.sqldelight:rxjava2-extensions:1.0.0-alpha5” app

  94. app queryWrapper.matchQueries.headToHead("Roger Federer", "Rafael Nadal") .asObservable(Schedulers.io()) .mapToList()

  95. iosApp db:native db app db:android

  96. iosApp struct HeadToHead { var player1Name: String var player2Name: String

    var player1Wins: NSNumber var player2Wins: NSNumber }
  97. iosApp func createHeadToHead( player1Name: String, player2Name: String, player1Wins: NSNumber, player2Wins:

    NSNumber ) -> HeadToHead { return HeadToHead( player1Name: player1Name, player2Name: player2Name, player1Wins: player1Wins, player2Wins: player2Wins ) }
  98. iosApp let headToHead = (database.matchQueries .headToHead( firstPlayerName: "Rafael Nadal", secondPlayerName:

    "Roger Federer", mapper: createHeadToHead ) .executeAsOne() as! HeadToHead)
  99. iosApp let headToHead = (database.matchQueries .headToHead( firstPlayerName: "Rafael Nadal", secondPlayerName:

    "Roger Federer", mapper: createHeadToHead ) .executeAsOne() as! HeadToHead)
  100. iosApp db:native db app db:android

  101. db class QueryTests { @Test fun headToHeadTest() { val headToHead

    = queryWrapper.matchQueries.headToHead( firstPlayerName = "Rafael Nadal", secondPlayerName = "Roger Federer" ).executeAsOne() assertEquals(headToHead.player1Name, "Rafael Nadal") assertEquals(headToHead.player1Wins, 23) assertEquals(headToHead.player2Name, "Roger Federer") assertEquals(headToHead.player2Wins, 15) } }
  102. SqlDelight 1.0 (alpha)

  103. Migrations

  104. Migrations CREATE TABLE player ( id INTEGER NOT NULL PRIMARY

    KEY, name TEXT NOT NULL, country TEXT NOT NULL );
  105. Migrations CREATE TABLE player ( id INTEGER NOT NULL PRIMARY

    KEY, name TEXT NOT NULL, country TEXT NOT NULL, ranking INTEGER NOT NULL );
  106. Migrations CREATE TABLE player ( id INTEGER NOT NULL PRIMARY

    KEY, name TEXT NOT NULL, country TEXT NOT NULL, ranking INTEGER NOT NULL ); Execution failed for task ':db:verifySqlDelightMigration'. > Error migrating from 1.db, fresh database looks different from migration database: /tables[player]/columns[player.ranking] - ADDED
  107. Migrations 1.sqm: ALTER TABLE player ADD COLUMN ranking INTEGER NOT

    NULL DEFAULT 0;
  108. Shorthands Tournament.sq: insertTournament: INSERT INTO tournament VALUES ?; build/TournamentQueries.sq: class

    TournamentQueries() { fun insertTournament(tournament: Tournament): Long }
  109. Shorthands Tournament.sq: selectForSurface: SELECT * FROM tournament WHERE surface IN

    ?; build/TournamentQueries.sq: class TournamentQueries() { fun selectForSurface( surface: Collection<Surface> ): Query<Tournament> }
  110. Testing dependencies { implementation “com.squareup.sqldelight:android-driver:1.0.0-alpha5” testImplementation “com.squareup.sqldelight:sqlite-driver:1.0.0-alpha5” }

  111. Testing dependencies { implementation “com.squareup.sqldelight:android-driver:1.0.0-alpha5” testImplementation “com.squareup.sqldelight:sqlite-driver:1.0.0-alpha5” } val database

    = SqliteJdbcOpenHelper() QueryWrapper.Helper.onCreate(database.getConnection())
  112. viewmodel + viewevent app api presenters protos backend db

  113. viewmodel + viewevent app api presenters protos backend db: android

    db
  114. viewmodel + viewevent app api presenters protos backend db: android

    db
  115. protos Wire protobufs parcelable

  116. protos protobufs parcelable

  117. protos parcelable

  118. protos

  119. viewmodel + viewevent app api presenters protos backend db: android

    db
  120. viewmodel + viewevent app api presenters protos backend db: android

    db
  121. viewmodel + viewevent app api presenters protos backend db: android

    db
  122. viewmodel + viewevent app api presenters protos backend db: android

    db
  123. None
  124. None
  125. None
  126. alec strong @strongolopolis