RxSwiftCommunity / RxGRDB

Reactive extensions for SQLite
MIT License
218 stars 35 forks source link

Should RxGRDB provide observable for migrations? #45

Closed cfilipov closed 6 years ago

cfilipov commented 6 years ago

I would like to share a pattern I am using to safely and easily wait for database initialization. I hope by sharing this it will help inform GRDB's design based on how it's being used and also catch anything that could be done by better utilizing GRDB's existing features.

I don't want any queries to execute before the db migration or loading of initial data set is complete. I also don't want to naively block the whole app while waiting. Without reactive programming this could get very messy. To accomplish this I wrap access to the db queue through an observable/signal so that the only way to get a queue is by observing the signal that creates it.

final class AppDatabase {

    let queue: DatabaseQueue

    // This is the only way to get an instance of the database queue
    // This signal never completes and only fires one event which contains an instance of ready AppDatabase
    // If you have an instance of AppDatabase then it's safe to query against it
    static func create(
        path: String,
        wipeDatabase: Bool = false,
        application: UIApplication? = nil
    ) -> Signal<AppDatabase, AnyError> {
        return SafeSignal.trying { try AppDatabase(path, wipeDatabase, application) }
            .executeOn(.global(qos: .background))
            .map(migration)
            .map(importData)
            .shareReplay(limit: 1)
    }

    private init(
        _ path: String,
        _ wipeDatabase: Bool = false,
        _ application: UIApplication? = nil
    ) throws {

        print("Database path: " + path)
        if wipeDatabase {
            print("Wiping away previous database...")
            try? FileManager.default.removeItem(atPath: path)
        }
        var config = Configuration()
        #if DEBUG
        config.trace = { print($0) }
        #endif
        self.queue = try DatabaseQueue(path: path, configuration: config)
        if let application = application {
            self.queue.setupMemoryManagement(in: application)
        }
    }

}

private func migration(db: AppDatabase) throws -> AppDatabase {
    var migrator = DatabaseMigrator()
    ...
    try migrator.migrate(db.queue)
    return db
}

private func importData(adb: AppDatabase) throws -> AppDatabase {
    ...
    return adb
}

// All queries as defined via extensions to AppDatabase
extension AppDatabase {
    func fetchDailyActivity(to date: Date) -> Signal<[DailyActivity], AnyError> {
        ...
    }
}

Now all db queries look something like this:

lazy var fetch = Env.database // once migration is complete we get a db instance (Env is a global, this should also have been done via dependency injection)
        .combineLatest(with: self.today) // input for the query, so when this changes we end up fetching again
        .flatMapLatest { db, day in db.fetchDailyActivity(to: day) } // The actual query

In the above, the query wont be executed until the database is ready. Additionally, since the input is also a signal, the query will be re-run when the input changes (like when significantTimeChange fires for example).

Some migrations may be fast, some larger migrations may take a while. A polite way to proceed is to only show a loading indicator if the migration takes "long enough". If the migration completes before the deadline no loading indicator should be shown. This will prevent the "quick flash" loading screen many apps have. The signal below is used in the root view controller to display a full screen loading indicator when a large migration takes place.

private let deadline = Property(())
    .toSignal()
    .delay(interval: 0.5)

/*
 Signal that the database is currently busy so that the UI can be blocked.
 This signal emits a value only if the database isn't ready before the deadline.
 If the debase becomes ready before the deadline the signal simply completes.
 */
lazy var databaseBusy = deadline.take(until: Env.database)
    .materialize()
groue commented 6 years ago

Thanks @cfilipov :-)

groue commented 6 years ago

But... should we keep this issue open? RxGRDB can not ship with your code, because it is too particular. We don't provide tools that lock users in a particular application architecture. Instead, we ship versatile tools that fit in as many setups as possible.