I recommend you consider bringing in the RxFirebase library. Rx is a great way to clean up nested closures (callback hell).
In looking over your sample code, the first thing you have to understand is that only so much can be done. The problem itself has a lot of essential complexity. Also, there's a lot going on in this code that can be broken out. Once you do that, you can boil down the problem to the following:
import Curry // the Curry library is in Cocoapods
func example(db: Firestore) -> Observable<[NewObject]> {
let getObjects = curry(getData(db:collectionId:documentId:))(db)
let xs = getObjects("documentA")("dataA")
let xys = xs.flatMap { parentsAndChildren(fn: getObjects("documentB"), parent: { $0 }, xs: $0) }
let xyzs = xys.flatMap { parentsAndChildren(fn: getObjects("documentC"), parent: { $0.1 }, xs: $0) }
return xyzs.mapT { NewObject(dataA: $0.0.0, dataB: $0.0.1, dataC: $0.1) }
}
Note that this is making extensive use of higher order functions, so understanding those will help a lot. If you don't want to use higher order functions, you could use classes instead, but the amount of code you would have to write would at least double and you would likely have problems with memory cycles.
To make the above so simple requires some support code:
func getData(db: Firestore, collectionId: String, documentId: String) -> Observable<[String]> {
return db.collection(collectionId).rx.getDocuments()
.map { getData(documentId: documentId, snapshot: $0) }
}
func parentsAndChildren<X>(fn: (String) -> Observable<[String]>, parent: (X) -> String, xs: [X]) -> Observable<[(X, String)]> {
Observable.combineLatest(xs.map { x in
fn(parent(x)).map { apply(x: x, ys: $0) }
})
.map { $0.flatMap { $0 } }
}
extension ObservableType {
func mapT<T, U>(_ transform: @escaping (T) -> U) -> Observable<[U]> where Element == [T] {
map { $0.map(transform) }
}
}
The getData(db:collectionId:documentId:) function asks for the strings in the collection associated with the document.
The parentsAndChildren(fn:parent:xs:) function is probably the most complex. It will extract the appropriate parent object from the generic X type, get the children from the server and roll them up into a single dimensional array of parents and children. For example if the parents are ["a", "b"], the children of "a" are ["w", "x"] and the children of "b" are ["y", "z"], then the function will output [("a", "w"), ("a", "x"), ("b", "y"), ("b", "z")] (contained in an Observable.)
The Observable.mapT(_:) function allows us to map through an Observable Array of objects and do something to them. Of course, you could just do xyzs.map { $0.map { NewObject(dataA: $0.0.0, dataB: $0.0.1, dataC: $0.1) } }, but I feel this is cleaner.
Here is the support code for the above functions:
extension Reactive where Base: CollectionReference {
func getDocuments() -> Observable<QuerySnapshot> {
Observable.create { [base] observer in
base.getDocuments { snapshot, error in
if let snapshot = snapshot {
observer.onNext(snapshot)
observer.onCompleted()
}
else {
observer.onError(error ?? RxError.unknown)
}
}
return Disposables.create()
}
}
}
func getData(documentId: String, snapshot: QuerySnapshot) -> [String] {
snapshot.documents.compactMap { $0.data()[documentId] as? String }
}
func apply<X>(x: X, ys: [String]) -> [(X, String)] {
ys.map { (x, $0) }
}
The Reactive.getDocuments() function actually makes the firebase request. Its job is to turn the callback closure into an object so that you can deal with it easier. This is the piece that RxFirebase should give you, but as you can see, it's pretty easy to write it on your own.
The getData(documentId:snapshot:) function just extracts the appropriate data out of the snapshot.
The app(x:ys:) function is what keeps the whole thing in a single dimensional array by copying the X for each child.
Lastly, notice that most of the functions above are easily and independently unit testable and the ones that aren't are exceptionally simple...