Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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.

Alec Strong

August 27, 2018
Tweet

More Decks by Alec Strong

Other Decks in Programming

Transcript

  1. 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
  2. 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
  3. 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 );
  4. 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
  5. 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)
  6. internal class RealInstrumentManager @Inject constructor( private val appService: AppService, private

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

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

    val queryWrapper: QueryWrapper, @Io private val ioScheduler: Scheduler ) : InstrumentManager backend
  9. 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
  10. 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 }
  11. 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 );
  12. 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 }
  13. com.squareup.sqldelight:runtime:1.0.0 class EnumColumnAdapter<T : Enum<T>> inline fun <reified T :

    Enum<T>> EnumColumnAdapter() : EnumColumnAdapter<T> { return EnumColumnAdapter(enumValues()) }
  14. 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 )
  15. 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
  16. db:android actual class DatabaseFactory( private val context: Context ) {

    actual fun create(): SqlDatabase { return QueryWrapper.create(context, "tennis.db") } }
  17. db fun QueryWrapper.Helper.create( databaseFactory: DatabaseFactory ): QueryWrapper { val queryWrapper

    = QueryWrapper( database = databaseFactory.create(), tournamentAdapter = Tournament.Adapter( surfaceAdapter = EnumColumnAdapter() ) ) }
  18. 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" }
  19. actual class DatabaseFactory { actual fun create(): SqlDatabase { val

    context = DefaultSystemContext() val factory = IosNativeOpenHelperFactory(context) return QueryWrapper.create( name = "tennis.db", openHelperFactory = factory ) } } db:native
  20. db val federer = 3L queryWrapper.playerQueries.insert( id = federer, name

    = "Roger Federer", country = "Switzerland" ) insert: INSERT INTO player (id, name, country) VALUES (?, ?, ?);
  21. 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) );
  22. 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 )
  23. 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) ) ;
  24. Match.sq: headToHead: SELECT ... db build/HeadToHead.kt: interface HeadToHead { val

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

    headToHead( firstPlayerName: String, secondPlayerName: String ): Query<HeadToHead> }
  26. 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>
  27. 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() } }
  28. db backend protos api presenters viewmodel + viewevent app interface

    InstrumentManager { @CheckResult fun withToken( token: String ): Observable<Instrument> }
  29. 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>
  30. iosApp struct HeadToHead { var player1Name: String var player2Name: String

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

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

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

    "Roger Federer", mapper: createHeadToHead ) .executeAsOne() as! HeadToHead)
  34. 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) } }
  35. Migrations CREATE TABLE player ( id INTEGER NOT NULL PRIMARY

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

    KEY, name TEXT NOT NULL, country TEXT NOT NULL, ranking INTEGER NOT NULL );
  37. 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
  38. Shorthands Tournament.sq: insertTournament: INSERT INTO tournament VALUES ?; build/TournamentQueries.sq: class

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

    ?; build/TournamentQueries.sq: class TournamentQueries() { fun selectForSurface( surface: Collection<Surface> ): Query<Tournament> }