From b540ad35e5700f8526973e60f106e508b67a4380 Mon Sep 17 00:00:00 2001 From: Justin Kolb Date: Wed, 16 Nov 2016 10:08:44 -0600 Subject: [PATCH] First attempt at a parallel promises implementation. --- FranticApparatus.xcodeproj/project.pbxproj | 12 ++ .../FranticApparatusDemo/Info.plist | 13 ++ .../RootViewController.swift | 133 ++++++++++++++--- README.md | 135 +++++++++++++++++- Sources/AllPromises.swift | 109 ++++++++++++++ Sources/AnyPromises.swift | 111 ++++++++++++++ Sources/PromiseError.swift | 25 ++++ Sources/RacePromises.swift | 89 ++++++++++++ 8 files changed, 600 insertions(+), 27 deletions(-) create mode 100644 Sources/AllPromises.swift create mode 100644 Sources/AnyPromises.swift create mode 100644 Sources/RacePromises.swift diff --git a/FranticApparatus.xcodeproj/project.pbxproj b/FranticApparatus.xcodeproj/project.pbxproj index fe1e047..b733484 100644 --- a/FranticApparatus.xcodeproj/project.pbxproj +++ b/FranticApparatus.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + E540D8691DDC78B700907960 /* AllPromises.swift in Sources */ = {isa = PBXBuildFile; fileRef = E540D8671DDC78B700907960 /* AllPromises.swift */; }; + E540D86A1DDC78B700907960 /* AnyPromises.swift in Sources */ = {isa = PBXBuildFile; fileRef = E540D8681DDC78B700907960 /* AnyPromises.swift */; }; + E540D86C1DDC78BE00907960 /* RacePromises.swift in Sources */ = {isa = PBXBuildFile; fileRef = E540D86B1DDC78BE00907960 /* RacePromises.swift */; }; E55CA21719C676DC00E66980 /* FranticApparatus.h in Headers */ = {isa = PBXBuildFile; fileRef = E55CA21619C676DC00E66980 /* FranticApparatus.h */; settings = {ATTRIBUTES = (Public, ); }; }; E55CA21D19C676DC00E66980 /* FranticApparatus.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E55CA21119C676DC00E66980 /* FranticApparatus.framework */; }; E57F6CE41D8DDA49006807C4 /* Deferred.swift in Sources */ = {isa = PBXBuildFile; fileRef = E57F6CD81D8DDA49006807C4 /* Deferred.swift */; }; @@ -34,6 +37,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + E540D8671DDC78B700907960 /* AllPromises.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AllPromises.swift; sourceTree = ""; }; + E540D8681DDC78B700907960 /* AnyPromises.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyPromises.swift; sourceTree = ""; }; + E540D86B1DDC78BE00907960 /* RacePromises.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RacePromises.swift; sourceTree = ""; }; E55CA21119C676DC00E66980 /* FranticApparatus.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FranticApparatus.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E55CA21519C676DC00E66980 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E55CA21619C676DC00E66980 /* FranticApparatus.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FranticApparatus.h; sourceTree = ""; }; @@ -132,6 +138,8 @@ E57F6CD71D8DDA49006807C4 /* Sources */ = { isa = PBXGroup; children = ( + E540D8671DDC78B700907960 /* AllPromises.swift */, + E540D8681DDC78B700907960 /* AnyPromises.swift */, E57F6CD81D8DDA49006807C4 /* Deferred.swift */, E57F6CD91D8DDA49006807C4 /* Dispatcher.swift */, E57F6CDB1D8DDA49006807C4 /* GCDDispatcher.swift */, @@ -140,6 +148,7 @@ E57F6CDE1D8DDA49006807C4 /* Promise.swift */, E57F6CDF1D8DDA49006807C4 /* PromiseError.swift */, E57F6CE01D8DDA49006807C4 /* PromiseMaker.swift */, + E540D86B1DDC78BE00907960 /* RacePromises.swift */, E57F6CE11D8DDA49006807C4 /* Result.swift */, E57F6CE21D8DDA49006807C4 /* State.swift */, E57F6CE31D8DDA49006807C4 /* Thenable.swift */, @@ -266,7 +275,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E540D86C1DDC78BE00907960 /* RacePromises.swift in Sources */, E57F6CEF1D8DDA49006807C4 /* Thenable.swift in Sources */, + E540D86A1DDC78B700907960 /* AnyPromises.swift in Sources */, E57F6CE81D8DDA49006807C4 /* Lock.swift in Sources */, E57F6CEA1D8DDA49006807C4 /* Promise.swift in Sources */, E57F6CEE1D8DDA49006807C4 /* State.swift in Sources */, @@ -277,6 +288,7 @@ E57F6CEB1D8DDA49006807C4 /* PromiseError.swift in Sources */, E57F6CE71D8DDA49006807C4 /* GCDDispatcher.swift in Sources */, E57F6CEC1D8DDA49006807C4 /* PromiseMaker.swift in Sources */, + E540D8691DDC78B700907960 /* AllPromises.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/FranticApparatusDemo/FranticApparatusDemo/Info.plist b/FranticApparatusDemo/FranticApparatusDemo/Info.plist index 3f2cd1b..8377d0d 100644 --- a/FranticApparatusDemo/FranticApparatusDemo/Info.plist +++ b/FranticApparatusDemo/FranticApparatusDemo/Info.plist @@ -51,5 +51,18 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSAppTransportSecurity + + NSExceptionDomains + + redditmedia.com + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + diff --git a/FranticApparatusDemo/FranticApparatusDemo/RootViewController.swift b/FranticApparatusDemo/FranticApparatusDemo/RootViewController.swift index 3a8a3bc..337554f 100644 --- a/FranticApparatusDemo/FranticApparatusDemo/RootViewController.swift +++ b/FranticApparatusDemo/FranticApparatusDemo/RootViewController.swift @@ -25,6 +25,10 @@ import UIKit import FranticApparatus +enum JSONError : Error { + case unexpectedJSON +} + class RootViewController : UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { var networkAPI: NetworkAPI! var collectionView: UICollectionView! @@ -41,6 +45,8 @@ class RootViewController : UIViewController, UICollectionViewDataSource, UIColle var images = [IndexPath : UIImage](minimumCapacity: 8) var errors = [IndexPath : Error](minimumCapacity: 8) var promises = [IndexPath : Promise](minimumCapacity: 8) + var thumbnailsPromise: Promise<[UIImage]>? + var thumbnails = [UIImage]() override func viewDidLoad() { super.viewDidLoad() @@ -65,12 +71,58 @@ class RootViewController : UIViewController, UICollectionViewDataSource, UIColle networkAPI = NetworkAPI(dispatcher: networkDispatcher, networkLayer: networkLayer) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if thumbnailsPromise == nil { + thumbnailsPromise = PromiseMaker.makeUsing(context: self) { (makePromise) in + makePromise { (context) -> Promise in + context.networkAPI.requestJSONObjectForURL(URL(string: "https://reddit.com/.json")!) + }.whenFulfilledThenPromise { (context, object) -> Promise<[UIImage]> in + let thumbnailURLs = try context.thumbnailsFromJSON(object: object) + let thumbnailPromises = thumbnailURLs.map({ context.networkAPI.requestImageForURL($0) }) + return all(thumbnailPromises) + }.whenFulfilled { (context, thumbnails) in + context.thumbnails = thumbnails + context.collectionView.reloadData() + }.whenRejected { (context, reason) in + NSLog("\(reason)") + }.whenComplete { (context) in + context.thumbnailsPromise = nil + } + } + } + } + + func thumbnailsFromJSON(object: NSDictionary) throws -> [URL] { + guard let data = object["data"] as? NSDictionary else { throw JSONError.unexpectedJSON } + guard let children = data["children"] as? NSArray else { throw JSONError.unexpectedJSON } + var thumbnailURLs = [URL]() + thumbnailURLs.reserveCapacity(children.count) + + for child in children { + if let childObject = child as? NSDictionary { + if let childData = childObject["data"] as? NSDictionary { + if let thumbnail = childData["thumbnail"] as? NSString { + if thumbnail.hasPrefix("http") { + if let thumbnailURL = URL(string: thumbnail as String) { + thumbnailURLs.append(thumbnailURL) + } + } + } + } + } + } + + return thumbnailURLs + } + func loadImage(at indexPath: IndexPath) { let model = models[indexPath.item] promises[indexPath] = PromiseMaker.makeUsing(context: self) { (makePromise) in - makePromise { (context) in - return context.networkAPI.requestImageForURL(model.url) + makePromise { (context) -> Promise in + context.networkAPI.requestImageForURL(model.url) }.whenFulfilled { (context, image) in context.images[indexPath] = image context.showImage(at: indexPath) @@ -125,38 +177,77 @@ class RootViewController : UIViewController, UICollectionViewDataSource, UIColle } } + func numberOfSections(in collectionView: UICollectionView) -> Int { + if thumbnails.count == 0 { + return 1 + } + else { + return 2 + } + } + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return models.count + if section == 0 { + return models.count + } + else if section == 1 { + return thumbnails.count + } + + return 0 } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "imageCell", for: indexPath) as? ImageCell { - if let image = images[indexPath] { - cell.hideActivity() - cell.image = image + if indexPath.section == 0 { + if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "imageCell", for: indexPath) as? ImageCell { + if let image = images[indexPath] { + cell.hideActivity() + cell.image = image + } + else if let error = errors[indexPath] { + cell.hideActivity() + cell.error = messageFor(error: error) + } + else { + cell.showActivity() + + if promises[indexPath] == nil { + loadImage(at: indexPath) + } + } + + return cell } - else if let error = errors[indexPath] { + else { + fatalError("No cell to display") + } + } + else if indexPath.section == 1 { + if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "imageCell", for: indexPath) as? ImageCell { + let thumbnail = thumbnails[indexPath.item] cell.hideActivity() - cell.error = messageFor(error: error) + cell.image = thumbnail + + return cell } else { - cell.showActivity() - - if promises[indexPath] == nil { - loadImage(at: indexPath) - } + fatalError("No cell to display") } - - return cell - } - else { - fatalError("No cell to display") } + + fatalError("Unexpected indexPath \(indexPath)") } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let model = models[indexPath.item] + if indexPath.section == 0 { + let model = models[indexPath.item] + + return CGSize(width: model.width, height: model.height) + } + else if indexPath.section == 1 { + return thumbnails[indexPath.item].size + } - return CGSize(width: model.width, height: model.height) + return CGSize.zero } } diff --git a/README.md b/README.md index fc91431..e45bb0a 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,13 @@ Promises provide a way to make it easier to read and write chains of dependent asynchronous code. Here is a simple example of how much better asynchronous code looks using FranticApparatus: ```swift -let url = NSURL(string: "http://example.com/image.png")! +func fetchImage(at url: URL) -> Promise { ... } +let url: URL = ... self.promise = PromiseMaker.makeUsing(context: self) { (makePromise) in - makePromise { (context) in + makePromise { (context) -> Promise in context.showActivityIndicator() - return context.fetchImage(url: url) + return context.fetchImage(at: url) }.whenFulfilled { (context, image) in context.showImage(image) }.whenRejected { (context, reason) in @@ -21,8 +22,34 @@ self.promise = PromiseMaker.makeUsing(context: self) { (makePromise) in } } ``` + +You can also wait for multiple promises to complete before continuing on like this: +```swift +func fetchURL(_ url: URL) -> Promise { ... } +let urls: [URL] = ... + +self.promise = PromiseMaker.makeUsing(context: self) { (makePromise) in + makePromise { (context) -> Promise<[Data]> in + context.showActivityIndicator() + let promises = urls.map({ context.fetchURL($0) }) + return all(promises) + }.whenFulfilled { (context, dataArray) in + context.processData(dataArray) + }.whenRejected { (context, reason) in + context.showError(reason) + }.whenComplete { (context) in + context.hideActivityIndicator() + context.promise = nil + } +} +``` +When the promise is fulfilled you will get an array of all the combined promised values. If any of the promises are rejected then the entire combined promise will also be rejected. + See the Demo for examples of how to make promises to fetch a set of images over the network using promises and display them in a `UICollectionView`. +## Changes for 6.1.0 +* New functions `all`, `any`, and `race`, that can turn multiple parallel promises into one promise. + ## Changes for 6.0.1 * [Multiplatform, Single-scheme](http://promisekit.org/news/2016/08/Multiplatform-Single-Scheme-Xcode-Projects/). @@ -97,9 +124,7 @@ See the Demo for examples of how to make promises to fetch a set of images over ## What is a promise? -A promise, at its most simple definition, is a proxy for a value that has not been calculated yet. This [blog](http://andyshora.com/promises-angularjs-explained-as-cartoon.html) provides a good high level overview of how they work. Unfortunately that does not give much insight into the usefulness they provide. The utility of promises arises because they are recursively composable, which makes for easily defining complex combinations of asynchronous functionality. Promises can be combined so they execute serially or in parallel*, but no matter which way you compose them they still effectively read like a serialized order of steps. Being able to write code that looks (as best it can) like it executes from top to bottom while actually wrapping multiple asynchronous calls is where the true power of promises lies. - -**Parallel promises are more tricky in a strongly typed language, and I am still working out a good way to implement it.* +A promise, at its most simple definition, is a proxy for a value that has not been calculated yet. This [blog](http://andyshora.com/promises-angularjs-explained-as-cartoon.html) provides a good high level overview of how they work. Unfortunately that does not give much insight into the usefulness they provide. The utility of promises arises because they are recursively composable, which makes for easily defining complex combinations of asynchronous functionality. Promises can be combined so they execute serially or in parallel, but no matter which way you compose them they still effectively read like a serialized order of steps. Being able to write code that looks (as best it can) like it executes from top to bottom while actually wrapping multiple asynchronous calls is where the true power of promises lies. You may be thinking to yourself that this sounds like it could be done just as well with normal asynchronous callbacks, and you would not be wrong. While you can do something similar using everyday blocks they quickly become ugly nests of callbacks and make error handling more difficult. As a simple example imagine you would like to download some data from a remote web service, parse that data as JSON, and then map that JSON into a data model object (also imagine you can not use your favorite networking library). It might look like the following (thread safe memory management included, strong error handling not included): ```swift @@ -327,6 +352,104 @@ The helpers in `Thenable` and `PromiseMaker` both follow the same naming scheme. Generally you want `whenRejected` to always be after a call to `whenFulfilled`, this is because `whenFulfilled` can throw errors and they will be silently passed along the chain unless there is another `whenRejected` later on to catch it. Also it is preferable to not throw errors from `whenComplete` and to make it the last in the chain. It will perform an action on both success and failure but will not gain access to the reason for failure. The demo does use a `whenComplete` in the middle of a chain, but any errors it could generate will be caught by the `whenRejected` handler that comes later on in the `RootViewController`. + +#### Parallel promises + +Starting with version 6.1 you can now use the `all`, `any`, and `race` global functions to generate parallel promises. These work by wraping up multiple promises for the same type of object and run them all at the same time concurrently. + +When you use `all` then all promises must complete successfully for the promise to fulfill, otherwise it will be rejected with the error from the first wrapped promise that fails. There are two versions of the `all` function. The first takes a collection of promises and the result will be an array of values. The second takes a dictionary of promises, where each promise has a unique key. The final result will be a dictionary of keys to values so you can grab the specific value for each specific promise. The dictionary form is useful for bundling up promises that return different types of values, which is discussed below. + +The `any` function takes a dictionary of promises and will fulfill if at least one of the wrapped promises complete successfully. When this happens you will get back an `AnyResult` object that contains a dictionary of values for the promises that were successfull and also a dictionary of all the errors for the failed promises. If all of them fail then the promise wil reject with ErrorDictionary which wraps up all the errors and their keys into one Error object. The `AnyResult` object has a helper method called `requiredValue(for:)` that provides a shortcut for extracting out any values that have to be successful no matter what, it will throw an error if the value is missing. Otherwise just access the `values` and `reasons` dictionaries directly to determine the actual results. + +Finaly there is the `race` function. It only takes a collection of promises and not a dictionary because you only get back one value, the one that finished first. If all of the wrapped promises happen to fail then it will be rejeceted with ErrorArray which wraps up all the errors into one Error object. + +#### Parallel promises with differing value types + +A shortcoming of having a strongly typed system is that when you have a collection of objects of different types you must use the least common denominator type that describes all of them. In the worst case this is `Any` or `AnyObject`, but even in the best case you lose the exact type of the object you are working with. The dictionary key in `any` or `all` helps to figure out what type you expect the returned value to have even if the returned type is something like `Any`. A more typesafe way to have multiple typed promises when using `any` or `all` is to create an `enum` where each case indicates which type is being returned and the associated value will contain the value of that type. For example: + +```swift +enum ReturnResults { + case image(UIImage) + case text(String) + case number(Int) +} + +func promiseThumbnail() -> Promise ... +func promiseTitle() -> Promise ... +func promiseAge() -> Promise ... + +struct ActualResult { + let thumbnail: UIImage + let title: String + let age: Int +} + +let thumbnailPromise = PromiseMaker.makeUsing(context: self) { (makePromise) in + makePromise { (context) -> Promise in + context.promiseThumbnail() + }.whenFulfilledThenTransform { (context, thumbnail) -> ReturnResults in + return .image(thumbnail) + } +} + +let titlePromise = PromiseMaker.makeUsing(context: self) { (makePromise) in + makePromise { (context) -> Promise in + context.promiseTitle() + }.whenFulfilledThenTransform { (context, title) -> ReturnResults in + return .text(title) + } +} + +let agePromise = PromiseMaker.makeUsing(context: self) { (makePromise) in + makePromise { (context) -> Promise in + context.promiseAge() + }.whenFulfilledThenTransform { (context, age) -> ReturnResults in + return .number(age) + } +} + +self.promise = PromiseMaker.makeUsing(context: self) { (makePromise) in + makePromise { (context) -> Promise<[String:ReturnResults]> in + all(["thumbnail": thumbnailPromise, "title": titlePromise, "age": agePromise]) + }.whenFulfilledThenTransform { (context, results) -> ActualResult in + // With the "all" function getting here means all the keys are present so using "!" should be OK + let thumbnailResult = results["thumbnail"]! + let titleResult = results["title"]! + let ageResult = results["age"]! + + let thumbnail: UIImage + let title: String + let age: Int + + switch thumbnailResult { + case .image(let image): + thumbnail = image + default: + fatalError("Programmer Error: If this happens thumbnailPromise was created incorrectly" + } + + switch titleResult { + case .text(let text): + title = text + default: + fatalError("Programmer Error: If this happens titlePromise was created incorrectly" + } + + switch ageResult { + case .number(let number): + age = number + default: + fatalError("Programmer Error: If this happens agePromise was created incorrectly" + } + + return ActualResult(thumbnail: thumbnail, title: title, age: age) + }.whenComplete { (context) in + context.promise = nil + } +} + +``` + ## Contact [Justin Kolb](mailto:franticapparatus@gmail.com) diff --git a/Sources/AllPromises.swift b/Sources/AllPromises.swift new file mode 100644 index 0000000..3c3d838 --- /dev/null +++ b/Sources/AllPromises.swift @@ -0,0 +1,109 @@ +/* + The MIT License (MIT) + + Copyright (c) 2016 Justin Kolb + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +public func all(_ promises: Promises) -> Promise<[Value]> where Promises.Iterator.Element == Promise { + return Promise<[Value]>(pending: promises) { (fulfill, reject) in + let all = AllPromises( + count: numericCast(promises.count), + fulfill: { (values) in + let sortedValues = values.sorted(by: { $0.key < $1.key }).map({ $0.value }) + + fulfill(sortedValues) + }, + reject: reject + ) + + for (index, promise) in promises.enumerated() { + promise.onResolve( + fulfill: { (value) in + all.fulfill(value: value, for: index) + }, + reject: { (reason) in + all.reject(reason: reason, for: index) + } + ) + } + } +} + +public func all(_ promises: [Key:Promise]) -> Promise<[Key:Value]> { + return Promise<[Key:Value]>(pending: promises) { (fulfill, reject) in + let all = AllPromises( + count: numericCast(promises.count), + fulfill: fulfill, + reject: reject + ) + + for (key, promise) in promises { + promise.onResolve( + fulfill: { (value) in + all.fulfill(value: value, for: key) + }, + reject: { (reason) in + all.reject(reason: reason, for: key) + } + ) + } + } +} + +fileprivate final class AllPromises { + fileprivate let lock: Lock + fileprivate let count: Int + fileprivate var values: [Key:Value] + fileprivate var reasons: [Key:Error] + fileprivate let fulfill: ([Key:Value]) -> Void + fileprivate let reject: (Error) -> Void + + fileprivate init(count: Int, fulfill: @escaping ([Key:Value]) -> Void, reject: @escaping (Error) -> Void) { + self.lock = Lock() + self.count = count + self.values = [Key:Value]() + self.reasons = [Key:Error]() + self.fulfill = fulfill + self.reject = reject + } + + fileprivate func fulfill(value: Value, for key: Key) { + lock.lock() + values[key] = value + + if values.count == count { + fulfill(values) + } + + lock.unlock() + } + + fileprivate func reject(reason: Error, for key: Key) { + lock.lock() + reasons[key] = reason + + if reasons.count == 1 { + reject(reason) + } + + lock.unlock() + } +} diff --git a/Sources/AnyPromises.swift b/Sources/AnyPromises.swift new file mode 100644 index 0000000..6413fe2 --- /dev/null +++ b/Sources/AnyPromises.swift @@ -0,0 +1,111 @@ +/* + The MIT License (MIT) + + Copyright (c) 2016 Justin Kolb + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +public struct AnyResult { + public let values: [Key:Value] + public let reasons: [Key:Error] + + public init(values: [Key:Value], reasons: [Key:Error]) { + precondition(values.count > 0) + precondition(reasons.count >= 0) + precondition(Set(values.keys).intersection(Set(reasons.keys)).count == 0) + self.values = values + self.reasons = reasons + } + + public func requiredValue(for key: Key) throws -> Value { + if let value = values[key] { + return value + } + else { + throw reasons[key] ?? PromiseError.unknownReason + } + } +} + +public func any(_ promises: [Key:Promise]) -> Promise> { + return Promise>(pending: promises) { (fulfill, reject) in + let any = AnyPromises( + count: promises.count, + fulfill: fulfill, + reject: { (reasons) in + reject(ErrorDictionary(errors: reasons)) + } + ) + + for (key, promise) in promises { + promise.onResolve( + fulfill: { (value) in + any.fulfill(value: value, for: key) + }, + reject: { (reason) in + any.reject(reason: reason, for: key) + } + ) + } + } +} + +fileprivate final class AnyPromises { + fileprivate let lock: Lock + fileprivate let count: Int + fileprivate let fulfill: (AnyResult) -> Void + fileprivate let reject: ([Key:Error]) -> Void + fileprivate var values: [Key:Value] + fileprivate var reasons: [Key:Error] + + fileprivate init(count: Int, fulfill: @escaping (AnyResult) -> Void, reject: @escaping ([Key:Error]) -> Void) { + self.lock = Lock() + self.count = count + self.fulfill = fulfill + self.reject = reject + self.values = [Key:Value](minimumCapacity: count) + self.reasons = [Key:Error](minimumCapacity: count) + } + + fileprivate func fulfill(value: Value, for key: Key) { + lock.lock() + values[key] = value + + if values.count + reasons.count == count { + fulfill(AnyResult(values: values, reasons: reasons)) + } + + lock.unlock() + } + + fileprivate func reject(reason: Error, for key: Key) { + lock.lock() + reasons[key] = reason + + if reasons.count == count { + reject(reasons) + } + else if values.count + reasons.count == count { + fulfill(AnyResult(values: values, reasons: reasons)) + } + + lock.unlock() + } +} diff --git a/Sources/PromiseError.swift b/Sources/PromiseError.swift index d8993eb..c58898b 100644 --- a/Sources/PromiseError.swift +++ b/Sources/PromiseError.swift @@ -24,4 +24,29 @@ public enum PromiseError : Error { case contextDeallocated + case unknownReason +} + +public struct ErrorArray : Error, CustomStringConvertible { + public let errors: [Error] + + public init(errors: [Error]) { + self.errors = errors + } + + public var description: String { + return self.errors.description + } +} + +public struct ErrorDictionary : Error, CustomStringConvertible { + public let errors: [Key:Error] + + public init(errors: [Key:Error]) { + self.errors = errors + } + + public var description: String { + return self.errors.description + } } diff --git a/Sources/RacePromises.swift b/Sources/RacePromises.swift new file mode 100644 index 0000000..4827a48 --- /dev/null +++ b/Sources/RacePromises.swift @@ -0,0 +1,89 @@ +/* + The MIT License (MIT) + + Copyright (c) 2016 Justin Kolb + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +public func race(_ promises: Promises) -> Promise where Promises.Iterator.Element == Promise { + return Promise(pending: promises) { (fulfill, reject) in + let race = RacePromises( + count: numericCast(promises.count), + fulfill: fulfill, + reject: { (reasons) in + reject(ErrorArray(errors: reasons)) + } + ) + + for promise in promises { + promise.onResolve( + fulfill: { (value) in + race.fulfill(value: value) + }, + reject: { (reason) in + race.reject(reason: reason) + } + ) + } + } +} + +fileprivate final class RacePromises { + fileprivate let lock: Lock + fileprivate let count: Int + fileprivate let fulfill: (Value) -> Void + fileprivate let reject: ([Error]) -> Void + fileprivate var values: [Value] + fileprivate var reasons: [Error] + + fileprivate init(count: Int, fulfill: @escaping (Value) -> Void, reject: @escaping ([Error]) -> Void) { + self.lock = Lock() + self.count = count + self.fulfill = fulfill + self.reject = reject + self.values = [Value]() + self.reasons = [Error]() + + values.reserveCapacity(count) + reasons.reserveCapacity(count) + } + + fileprivate func fulfill(value: Value) { + lock.lock() + values.append(value) + + if values.count == 1 { + fulfill(value) + } + + lock.unlock() + } + + fileprivate func reject(reason: Error) { + lock.lock() + reasons.append(reason) + + if reasons.count == count { + reject(reasons) + } + + lock.unlock() + } +}