Search code examples
swiftcombineuserdefaultsgrdbgrdbcombine

How can I create a Swift Combine publisher from two publishers A and B where publisher B consumes the value from publisher A?


I want to create a Swift Combine publisher which achieves the following:

  • The publisher should be triggered by changes in either Defaults (a UserDefaults Swift package) or changes in GRDB sqlite database values (using GRDBCombine).
  • The updated UserDefaults received from the Defaults publisher should be used within the database query in the GRDBCombine publisher.

Here is a simplified version of what I have tried so far:

func tasksPublisher() -> AnyPublisher<[Task], Never> {
    Defaults.publisher(.myUserDefault)
        .flatMap { change in
            let myUserDefault = change.newValue

            return ValueObservation
                .tracking { db in
                    try Task.
                        .someFilter(myUserDefault)
                        .fetchAll(db)
                }
                .removeDuplicates()
                .publisher(in: database)
                .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
}

However, this publisher produces the following error (edited according to the simplified version of my publisher above):

Cannot convert return expression of type 'AnyPublisher<Publishers.FlatMap<_, AnyPublisher<Defaults.KeyChange<Int>, Never>>.Output, Publishers.FlatMap<_, AnyPublisher<Defaults.KeyChange<Int>, Never>>.Failure>' (aka 'AnyPublisher<_.Output, Never>') to return type 'AnyPublisher<[Task], Never>'

My bet is that there is a problem with the two publishers having different values: [Task] and Defaults.KeyChange<Int>. However, I cannot find a way to work around this.


Solution

  • Assuming you want to start a new database publisher each time the Defaults publisher emits a change, you need the switchToLatest() operator.

    This operator needs errors from both publishers to be harmonized. Here, since Defaults.publisher has the Never failure type, we can use the setFailureType(to:) operator in order to converge on the database publisher failure type: Error.

    This gives:

    func tasksPublisher() -> AnyPublisher<[Task], Error> {
        Defaults
            .publisher(.myUserDefault)
            .setFailureType(to: Error.self)
            .map({ change -> DatabasePublishers.Value<[Task]> in
                let myUserDefault = change.newValue
                return ValueObservation
                    .tracking { db in
                        try Task
                            .someFilter(myUserDefault)
                            .fetchAll(db)
                    }
                    .removeDuplicates()
                    .publisher(in: database)
            })
            .switchToLatest()
            .eraseToAnyPublisher()
    }
    

    Note that the returned publisher has the Error failure type, because the database is not 100% reliable, as all I/O externalities. It is difficult, in a Stack Overflow answer, to recommend hiding errors at this point (by turning them into an empty Task array, for example), because hiding errors prevents your app from knowing what's wrong and react accordingly.

    Yet here is a version below that traps on database errors. This is the version I would use, assuming the app just can't run when SQLite does not work: it's sometimes useless to pretend such low-level errors can be caught and processed in a user-friendly way.

    // Traps on database error
    func tasksPublisher() -> AnyPublisher<[Task], Never> {
        Defaults
            .publisher(.myUserDefault)
            .map({ change -> AnyPublisher<[Task], Never> in
                let myUserDefault = change.newValue
                return ValueObservation
                    .tracking { db in
                        try Task
                            .someFilter(myUserDefault)
                            .fetchAll(db)
                    }
                    .removeDuplicates()
                    .publisher(in: database)
                    .assertNoFailure("Unexpected database failure")
                    .eraseToAnyPublisher()
            })
            .switchToLatest()
            .eraseToAnyPublisher()
    }