From 4fc7792c0c2155242af056b56985cd716121172f Mon Sep 17 00:00:00 2001 From: otrepanier Date: Fri, 20 Nov 2020 14:14:27 -0800 Subject: [PATCH 1/6] implement Promise.fold --- lib/init.lua | 18 ++++++++++++++++++ lib/init.spec.lua | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/lib/init.lua b/lib/init.lua index 392f4e7..3b9c87b 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -439,6 +439,24 @@ function Promise.all(promises) return Promise._all(debug.traceback(nil, 2), promises) end +function Promise.fold(list, callback, initialValue) + assert(type(list) == "table", "Bad argument #1 to Promise.fold: must be a table") + assert(type(callback) == "function", "Bad argument #2 to Promise.fold: must be a function") + + local previousValue = initialValue + for i = 1, #list do + local element = list[i] + if Promise.is(previousValue) then + previousValue = previousValue:andThen(function(previousValueResolved) + return callback(previousValueResolved, element, i) + end) + else + previousValue = callback(previousValue, element, i) + end + end + return previousValue +end + function Promise.some(promises, amount) assert(type(amount) == "number", "Bad argument #2 to Promise.some: must be a number") diff --git a/lib/init.spec.lua b/lib/init.spec.lua index e2c611d..f4ea3bf 100644 --- a/lib/init.spec.lua +++ b/lib/init.spec.lua @@ -811,6 +811,51 @@ return function() end) end) + describe("Promise.fold", function() + it("should return the initial value when the list is empty", function() + local initialValue = {} + local result = Promise.fold({}, function() + error("should not be called") + end, initialValue) + expect(result).to.equal(initialValue) + end) + + it("should fold the list if the reducer never returns promises", function() + local sum = Promise.fold({1, 2, 3}, function(sum, element) + return sum + element + end, 0) + expect(sum).to.equal(6) + end) + + it("should fold the list into a promise if the reducer returns at least a promise", function() + local sum = Promise.fold({1, 2, 3}, function(sum, element, index) + if index == 2 then + return Promise.resolve(sum + element) + else + return sum + element + end + end, 0) + expect(Promise.is(sum)).to.equal(true) + expect(sum:getStatus()).to.equal(Promise.Status.Resolved) + expect(sum:expect()).to.equal(6) + end) + + it("should return the first rejected promise", function() + local errorMessage = "foo" + local sum = Promise.fold({1, 2, 3}, function(sum, element, index) + if index == 2 then + return Promise.reject(errorMessage) + else + return sum + element + end + end, 0) + expect(Promise.is(sum)).to.equal(true) + local status, rejection = sum:awaitStatus() + expect(status).to.equal(Promise.Status.Rejected) + expect(rejection).to.equal(errorMessage) + end) + end) + describe("Promise.race", function() it("should resolve with the first settled value", function() local promise = Promise.race({ From 9d554f236a139d248230e6224a1f67348bd25c52 Mon Sep 17 00:00:00 2001 From: otrepanier Date: Fri, 20 Nov 2020 15:11:41 -0800 Subject: [PATCH 2/6] update documentation --- lib/README.md | 77 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/lib/README.md b/lib/README.md index 61aa8f5..e540d16 100644 --- a/lib/README.md +++ b/lib/README.md @@ -22,7 +22,7 @@ docs: tags: [ 'read only', 'static', 'enums' ] type: Status desc: A table containing all members of the `Status` enum, e.g., `Promise.Status.Resolved`. - + functions: - name: new @@ -45,9 +45,9 @@ docs: ``` You do not need to use `pcall` within a Promise. Errors that occur during execution will be caught and turned into a rejection automatically. If `error()` is called with a table, that table will be the rejection value. Otherwise, string errors will be converted into `Promise.Error(Promise.Error.Kind.ExecutionError)` objects for tracking debug information. - + You may register an optional cancellation hook by using the `onCancel` argument: - * This should be used to abort any ongoing operations leading up to the promise being settled. + * This should be used to abort any ongoing operations leading up to the promise being settled. * Call the `onCancel` function with a function callback as its only argument to set a hook which will in turn be called when/if the promise is cancelled. * `onCancel` returns `true` if the Promise was already cancelled when you called `onCancel`. * Calling `onCancel` with no argument will not override a previously set cancellation hook, but it will still return `true` if the Promise is currently cancelled. @@ -89,7 +89,7 @@ docs: The same as [[Promise.new]], except execution begins after the next `Heartbeat` event. This is a spiritual replacement for `spawn`, but it does not suffer from the same [issues](https://eryn.io/gist/3db84579866c099cdd5bb2ff37947cec) as `spawn`. - + ```lua local function waitForChild(instance, childName, timeout) return Promise.defer(function(resolve, reject) @@ -99,7 +99,7 @@ docs: end) end ``` - + static: true params: - name: deferExecutor @@ -184,7 +184,7 @@ docs: local isPlayerInGroup = Promise.promisify(function(player, groupId) return player:IsInGroup(groupId) end) - ``` + ``` static: true params: - name: callback @@ -202,7 +202,7 @@ docs: returns: - name: "*" desc: The return values from the wrapped function. - + - name: resolve desc: Creates an immediately resolved Promise with the given value. static: true @@ -234,13 +234,13 @@ docs: static: true params: "value: ...any" returns: Promise<...any> - + - name: all desc: | Accepts an array of Promises and returns a new promise that: * is resolved after all input promises resolve. * is rejected if *any* input promises reject. - + Note: Only the first return value from each promise will be present in the resulting array. After any input Promise rejects, all other input Promises that are still pending will be cancelled if they have no other consumers. @@ -318,7 +318,7 @@ docs: static: true params: "promises: array>, count: number" returns: Promise> - + - name: any desc: | Accepts an array of Promises and returns a Promise that is resolved as soon as *any* of the input Promises resolves. It will reject only if *all* input Promises reject. As soon as one Promises resolves, all other pending Promises are cancelled if they have no other consumers. @@ -356,11 +356,34 @@ docs: returns: Promise static: true + - name: fold + since: unreleased + desc: | + Folds an array into a promise or a value. The array is traversed sequentially and + + The reducer function can return a promise or directly a value. It always received the last resolved value. + + The folding will stop at the first rejection encountered. + ``` + params: + - name: list + type: "array" + - name: reducer + desc: The function to call with the accumulated value, the current element from the array and its index. + type: + kind: function + params: "accumulator: U, value: T, index: number" + returns: U | Promise + - name: initialValue + type: "U" + returns: U | Promise + static: true + - name: each since: 3.0.0 desc: | Iterates serially over the given an array of values, calling the predicate callback on each value before continuing. - + If the predicate returns a Promise, we wait for that Promise to resolve before moving on to the next item in the array. @@ -395,14 +418,14 @@ docs: > 4) Got qux! ]] ``` - + If the Promise a predicate returns rejects, the Promise from `Promise.each` is also rejected with the same value. If the array of values contains a Promise, when we get to that point in the list, we wait for the Promise to resolve before calling the predicate with the value. - + If a Promise in the array of values is already Rejected when `Promise.each` is called, `Promise.each` rejects with that value immediately (the predicate callback will never be called even once). If a Promise in the list is already Cancelled when `Promise.each` is called, `Promise.each` rejects with `Promise.Error(Promise.Error.Kind.AlreadyCancelled`). If a Promise in the array of values is Started at first, but later rejects, `Promise.each` will reject with that value and iteration will not continue once iteration encounters that value. - Returns a Promise containing an array of the returned/resolved values from the predicate for each item in the array of values. + Returns a Promise containing an array of the returned/resolved values from the predicate for each item in the array of values. If this Promise returned from `Promise.each` rejects or is cancelled for any reason, the following are true: - Iteration will not continue. @@ -507,7 +530,7 @@ docs: desc: Checks whether the given object is a Promise via duck typing. This only checks if the object is a table and has an `andThen` method. static: true params: "object: any" - returns: + returns: - type: boolean desc: "`true` if the given `object` is a Promise." @@ -548,7 +571,7 @@ docs: params: "...: ...any?" returns: Promise returns: Promise - + - name: catch desc: | Shorthand for `Promise:andThen(nil, failureHandler)`. @@ -558,7 +581,7 @@ docs: ::: warning Within the failure handler, you should never assume that the rejection value is a string. Some rejections within the Promise library are represented by [[Error]] objects. If you want to treat it as a string for debugging, you should call `tostring` on it first. ::: - params: + params: - name: failureHandler type: kind: function @@ -587,14 +610,14 @@ docs: ``` If you return a Promise from the tap handler callback, its value will be discarded but `tap` will still wait until it resolves before passing the original value through. - params: + params: - name: tapHandler type: kind: function params: "...: ...any?" returns: ...any? returns: Promise<...any?> - + - name: finally desc: | Set a handler that will be called regardless of the promise's fate. The handler is called when the promise is resolved, rejected, *or* cancelled. @@ -628,8 +651,8 @@ docs: type: kind: function params: "status: Status" - returns: ...any? - returns: Promise<...any?> + returns: ...any? + returns: Promise<...any?> overloads: - params: - name: finallyHandler @@ -657,8 +680,8 @@ docs: type: kind: function params: "status: Status" - returns: ...any? - returns: Promise<...any?> + returns: ...any? + returns: Promise<...any?> overloads: - params: - name: doneHandler @@ -787,7 +810,7 @@ docs: return "some", "values" end) ``` - + params: - name: "..." type: "...any?" @@ -878,7 +901,7 @@ docs: type: boolean - desc: The values that the Promise resolved or rejected with. type: ...any? - + - name: awaitStatus tags: [ 'yields' ] desc: Yields the current thread until the given Promise completes. Returns the Promise's status, followed by the values that the promise resolved or rejected with. @@ -907,12 +930,12 @@ docs: ```lua select(2, assert(promise:await())) ``` - + **Errors** if the Promise rejects or gets cancelled. returns: - type: ...any? desc: The values that the Promise resolved with. - + - name: getStatus desc: Returns the current Promise status. returns: Status From df07638f432d6a25881b19dac95b1374fcb98aca Mon Sep 17 00:00:00 2001 From: otrepanier Date: Fri, 20 Nov 2020 15:11:53 -0800 Subject: [PATCH 3/6] update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index beb58ac..cc3da24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased changes + +### Added +- Added `Promise.fold` (#47) + ## [3.0.1] - 2020-08-24 ### Fixed - Make `Promise.is` work with promises from old versions of the library (#41) From 1ac7d3cae9514b93669722ec661a7619152ef77e Mon Sep 17 00:00:00 2001 From: otrepanier Date: Mon, 30 Nov 2020 16:34:13 -0800 Subject: [PATCH 4/6] refactor implementation to support cancel --- lib/README.md | 20 ++++++++++++++++---- lib/init.lua | 16 +++++++--------- lib/init.spec.lua | 39 ++++++++++++++++++++++++++++++++------- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/lib/README.md b/lib/README.md index e540d16..b2ac9f1 100644 --- a/lib/README.md +++ b/lib/README.md @@ -359,15 +359,27 @@ docs: - name: fold since: unreleased desc: | - Folds an array into a promise or a value. The array is traversed sequentially and + Folds an array of values or promises into a single value. The array is traversed sequentially. - The reducer function can return a promise or directly a value. It always received the last resolved value. + The reducer function can return a promise or value directly. Each iteration receives the resolved value from the previous, and the first receives your defined initial value. The folding will stop at the first rejection encountered. + ```lua + local basket = {"blueberry", "melon", "pear", "melon"} + Promise.fold(basket, function(accumulatedCost, fruit) + if fruit == "blueberry" then + return cost -- blueberries are free! + else + -- call a function that returns a promise with the fruit price + return fetchPrice(fruit):andThen(function(fruitCost) + return cost + fruitCost + end) + end + end, 0) ``` params: - name: list - type: "array" + type: "array>" - name: reducer desc: The function to call with the accumulated value, the current element from the array and its index. type: @@ -376,7 +388,7 @@ docs: returns: U | Promise - name: initialValue type: "U" - returns: U | Promise + returns: Promise static: true - name: each diff --git a/lib/init.lua b/lib/init.lua index 3b9c87b..e5a77ad 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -443,18 +443,16 @@ function Promise.fold(list, callback, initialValue) assert(type(list) == "table", "Bad argument #1 to Promise.fold: must be a table") assert(type(callback) == "function", "Bad argument #2 to Promise.fold: must be a function") - local previousValue = initialValue - for i = 1, #list do - local element = list[i] - if Promise.is(previousValue) then - previousValue = previousValue:andThen(function(previousValueResolved) - return callback(previousValueResolved, element, i) + local accumulator = initialValue + return Promise.each(list, function(resolvedElement, i) + if Promise.is(accumulator) then + accumulator = accumulator:andThen(function(previousValueResolved) + return callback(previousValueResolved, resolvedElement, i) end) else - previousValue = callback(previousValue, element, i) + accumulator = callback(accumulator, resolvedElement, i) end - end - return previousValue + end):andThenReturn(accumulator) end function Promise.some(promises, amount) diff --git a/lib/init.spec.lua b/lib/init.spec.lua index f4ea3bf..1bfc996 100644 --- a/lib/init.spec.lua +++ b/lib/init.spec.lua @@ -812,30 +812,37 @@ return function() end) describe("Promise.fold", function() - it("should return the initial value when the list is empty", function() + it("should return the initial value in a promise when the list is empty", function() local initialValue = {} local result = Promise.fold({}, function() error("should not be called") end, initialValue) - expect(result).to.equal(initialValue) + + expect(Promise.is(result)).to.equal(true) + expect(result:getStatus()).to.equal(Promise.Status.Resolved) + expect(result:expect()).to.equal(initialValue) end) - it("should fold the list if the reducer never returns promises", function() - local sum = Promise.fold({1, 2, 3}, function(sum, element) + it("should accept promises in the list", function() + local sum = Promise.fold({Promise.resolve(1), 2, 3}, function(sum, element) return sum + element end, 0) - expect(sum).to.equal(6) + expect(Promise.is(sum)).to.equal(true) + expect(sum:getStatus()).to.equal(Promise.Status.Resolved) + expect(sum:expect()).to.equal(6) end) - it("should fold the list into a promise if the reducer returns at least a promise", function() + it("should always return a promise even if the list or reducer don't use them", function() local sum = Promise.fold({1, 2, 3}, function(sum, element, index) if index == 2 then - return Promise.resolve(sum + element) + return Promise.delay(1):andThenReturn(sum + element) else return sum + element end end, 0) expect(Promise.is(sum)).to.equal(true) + expect(sum:getStatus()).to.equal(Promise.Status.Started) + advanceTime(2) expect(sum:getStatus()).to.equal(Promise.Status.Resolved) expect(sum:expect()).to.equal(6) end) @@ -854,6 +861,24 @@ return function() expect(status).to.equal(Promise.Status.Rejected) expect(rejection).to.equal(errorMessage) end) + + it("should return the first canceled promise", function() + local secondPromise + local sum = Promise.fold({1, 2, 3}, function(sum, element, index) + if index == 1 then + return sum + element + elseif index == 2 then + secondPromise = Promise.delay(1):andThenReturn(sum + element) + return secondPromise + else + error('this should not run if the promise is cancelled') + end + end, 0) + expect(Promise.is(sum)).to.equal(true) + expect(sum:getStatus()).to.equal(Promise.Status.Started) + secondPromise:cancel() + expect(sum:getStatus()).to.equal(Promise.Status.Cancelled) + end) end) describe("Promise.race", function() From ce6c12f177b936411dbd2b30f6beb688ddbd7b96 Mon Sep 17 00:00:00 2001 From: otrepanier Date: Mon, 30 Nov 2020 16:42:21 -0800 Subject: [PATCH 5/6] remove promise check --- lib/init.lua | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/init.lua b/lib/init.lua index e5a77ad..c017b86 100644 --- a/lib/init.lua +++ b/lib/init.lua @@ -443,15 +443,11 @@ function Promise.fold(list, callback, initialValue) assert(type(list) == "table", "Bad argument #1 to Promise.fold: must be a table") assert(type(callback) == "function", "Bad argument #2 to Promise.fold: must be a function") - local accumulator = initialValue + local accumulator = Promise.resolve(initialValue) return Promise.each(list, function(resolvedElement, i) - if Promise.is(accumulator) then - accumulator = accumulator:andThen(function(previousValueResolved) - return callback(previousValueResolved, resolvedElement, i) - end) - else - accumulator = callback(accumulator, resolvedElement, i) - end + accumulator = accumulator:andThen(function(previousValueResolved) + return callback(previousValueResolved, resolvedElement, i) + end) end):andThenReturn(accumulator) end From 73e91d89dd531cb3a303ee07564a36dd3a76723b Mon Sep 17 00:00:00 2001 From: Eryn Lynn Date: Tue, 1 Dec 2020 20:18:02 -0500 Subject: [PATCH 6/6] Fix typo --- lib/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/README.md b/lib/README.md index b2ac9f1..42e3f4c 100644 --- a/lib/README.md +++ b/lib/README.md @@ -366,7 +366,7 @@ docs: The folding will stop at the first rejection encountered. ```lua local basket = {"blueberry", "melon", "pear", "melon"} - Promise.fold(basket, function(accumulatedCost, fruit) + Promise.fold(basket, function(cost, fruit) if fruit == "blueberry" then return cost -- blueberries are free! else