Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make working with promises of promises easier #3813

Merged
merged 26 commits into from
Aug 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .release-notes/next-unwrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
## Make working with Promise[Promise[B]] chains easier

We've added a new method `flatten_next` to `Promise` in order to make working with promises of promises easier.

`flatten_next` is a companion to `next`. It operates in an identical fashion except for the type of the fulfilled promise. Whereas `next` takes a function that returns a type `B`, `flatten_next` takes a function that returns `Promise[B]`.

Why is `flatten_next` valuable given that next could take a `B` that is of a type like `Promise[String]`? Let's start with some code to demonstrate the problem that arises when returning `Promise[Promise[B]]` from `next`.

Let's say we have a library for accessing the GitHub REST API:

```pony
class GitHub
new create(personal_access_token: String)

fun get_repo(repo: String): Promise[Repository]
ergl marked this conversation as resolved.
Show resolved Hide resolved

class Repo
fun get_issue(number: I64): Promise[Issue]

class Issue
fun title(): String
```

And we want to use this promise based API to look up the title of an issue. Without `flatten_next`, we could attempt to do the following using `next`:

```pony
actor Main
new create(env: Env) =>
let repo: Promise[Repo] =
GitHub("my token").get_repo("ponylang/ponyc")

//
// do something with the repo once the promise is fulfilled
// in our case, get the issue
//

let issue = Promise[Promise[Issue]] =
repo.next[Promise[Issue]](FetchIssue~apply(1))

// once we get the issue, print the title
issue.next[None](PrintIssueTitle~apply(env.out))

primitive FetchIssue
fun apply(number: I64, repo: Repo): Promise[Issue] =>
repo.get_issue(number)

primitive PrintIssueTitle
fun apply(out: OutStream, issue: Promise[Issue]) =>
// O NO! We can't print the title
// We don't have an issue, we have a promise for an issue
```

Take a look at what happens in the example, when we get to `PrintIssueTitle`, we can't print anything because we "don't have anything". In order to print the issue title, we need an `Issue` not a `Promise[Issue]`.

We could solve this by doing something like this:

```pony
primitive PrintIssueTitle
fun apply(out: OutStream, issue: Promise[Issue]) =>
issue.next[None](ActuallyPrintIssueTitle~apply(out))

primitive ActuallyPrintIssueTitle
fun apply(out: OutStream, issue: Issue) =>
out.print(issue.title())
```

That will work, however, it is kind of awful. When looking at:

```pony
let repo: Promise[Repo] =
GitHub("my token").get_repo("ponylang/ponyc")
let issue = Promise[Promise[Issue]] =
repo.next[Promise[Issue]](FetchIssue~apply(1))
issue.next[None](PrintIssueTitle~apply(env.out))
```

it can be hard to follow what is going on. We can only tell what is happening because we gave `PrintIssueTitle` a very misleading name; it doesn't print an issue title.

`flatten_next` addresses the problem of "we want the `Issue`, not the intermediate `Promise`". `flatten_next` takes an intermediate promise and unwraps it into the fulfilled type. You get to write your promise chain without having to worry about intermediate promises.

Updated to use `flatten_next`, our API example becomes:

```pony
actor Main
new create(env: Env) =>
let repo: Promise[Repo] =
GitHub("my token").get_repo("ponylang/ponyc")

let issue = Promise[Issue] =
repo.flatten_next[Issue](FetchIssue~apply(1))

issue.next[None](PrintIssueTitle~apply(env.out))

primitive FetchIssue
fun apply(number: I64, repo: Repo): Promise[Issue] =>
repo.get_issue(number)

primitive PrintIssueTitle
fun apply(out: OutStream, issue: Issue) =>
out.print(issue.title())
```

Our promise `Issue`, is no longer a `Promise[Promise[Issue]]`. By using `flatten_next`, we have a much more manageable `Promise[Issue]` instead.

Other than unwrapping promises for you, `flatten_next` otherwise acts the same as `next` so all the same rules apply to fulfillment and rejection.
202 changes: 202 additions & 0 deletions packages/promises/_test.pony
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ actor Main is TestList
test(_TestPromiseTimeout)
test(_TestPromisesJoin)
test(_TestPromisesJoinThenReject)
test(_TestFlattenNextHappyPath)
test(_TestFlattenNextFirstHasFulfillError)
test(_TestFlattenNextSecondHasFulfillError)
test(_TestFlattenNextRejectFirst)

class iso _TestPromise is UnitTest
fun name(): String => "promises/Promise"
Expand Down Expand Up @@ -145,3 +149,201 @@ class iso _TestPromisesJoinThenReject is UnitTest
a("a")
b("b")
c.reject()

class iso _TestFlattenNextHappyPath is UnitTest
"""
This is the happy path, everything works `flatten_next` test.

If a reject handler, gets called, it would be a bug.
"""
fun name(): String =>
"promises/Promise.flatten_next_happy_path"

fun apply(h: TestHelper) =>
let initial_string = "foo"
let second_string = "bar"

h.long_test(2_000_000_000)
h.expect_action(initial_string)
h.expect_action(second_string)

let start = Promise[String]
let inter = start.flatten_next[String](
_FlattenNextFirstPromise~successful_fulfill(
second_string, h, initial_string),
_FlattenNextFirstPromise~fail_if_reject_is_called(h)
)
inter.next[None](
_FlattenNextSecondPromise~successful_fulfill(h, second_string),
_FlattenNextSecondPromise~fail_if_reject_is_called(h)
)

start(initial_string)

class iso _TestFlattenNextFirstHasFulfillError is UnitTest
"""
Two promises chained `flatten_next` test where the fulfill action for the
first promise fails.

This should result in the rejection handler for the second promise being run.

Neither the rejection handler for the first promise, nor the fulfillment
handler for the second promise should be run.
"""
fun name(): String =>
"promises/Promise.flatten_next_first_has_fulfill_error"

fun apply(h: TestHelper) =>
let initial_string = "foo"
let expected_reject_string = "rejected"

h.long_test(2_000_000_000)
h.expect_action(initial_string)
h.expect_action(expected_reject_string)

let start = Promise[String]
let inter = start.flatten_next[String](
_FlattenNextFirstPromise~fulfill_will_error(h, initial_string),
_FlattenNextFirstPromise~fail_if_reject_is_called(h)
)
inter.next[None](
_FlattenNextSecondPromise~fail_if_fulfill_is_called(h),
_FlattenNextSecondPromise~reject_expected(h, expected_reject_string)
)

start(initial_string)


class iso _TestFlattenNextSecondHasFulfillError is UnitTest
"""
Two promises chained `flatten_next` test where the fulfill action for the
second promise fails.

Neither the reject handler should be called.
"""
fun name(): String =>
"promises/Promise.flatten_next_second_has_fulfill_error"

fun apply(h: TestHelper) =>
let initial_string = "foo"
let second_string = "bar"

h.long_test(2_000_000_000)
h.expect_action(initial_string)
h.expect_action(second_string)

let start = Promise[String]
let inter = start.flatten_next[String](
_FlattenNextFirstPromise~successful_fulfill(
second_string, h, initial_string),
_FlattenNextFirstPromise~fail_if_reject_is_called(h)
)
inter.next[None](
_FlattenNextSecondPromise~fulfill_will_error(h, second_string),
_FlattenNextSecondPromise~fail_if_reject_is_called(h)
)

start(initial_string)

class iso _TestFlattenNextRejectFirst is UnitTest
"""
Two promises chained `flatten_next` test where the first promise is rejected.

The reject action for both promises should be run.

Neither fulfill handler should be run.
"""
fun name(): String =>
"promises/Promise.flatten_next_reject_first"

fun apply(h: TestHelper) =>
let initial_string = "foo"
let first_reject = "first rejected"
let second_reject = "second rejected"

h.long_test(2_000_000_000)
h.expect_action(first_reject)
h.expect_action(second_reject)

let start = Promise[String]
let inter = start.flatten_next[String](
_FlattenNextFirstPromise~fail_if_fulfill_is_called(h),
_FlattenNextFirstPromise~reject_expected(h, first_reject)
)
inter.next[None](
_FlattenNextSecondPromise~fail_if_fulfill_is_called(h),
_FlattenNextSecondPromise~reject_expected(h, second_reject)
)

start.reject()

primitive _FlattenNextFirstPromise
"""
Callback functions called after fulfilled/reject is called on the 1st
promise in our `flatten_next` tests.

All of the `flatten_next` tests a 2nd promise chained so none of these
actions should ever call `complete` on the `TestHelper` as there's another
promise in the chain.
"""
fun successful_fulfill(send_on: String, h: TestHelper,
expected: String,
actual: String): Promise[String]
=>
h.assert_eq[String](expected, actual)
h.complete_action(expected)
let p = Promise[String]
p(send_on)
p

fun fulfill_will_error(h: TestHelper,
expected: String,
actual: String): Promise[String] ?
=>
h.assert_eq[String](expected, actual)
h.complete_action(expected)
error

jasoncarr0 marked this conversation as resolved.
Show resolved Hide resolved
fun fail_if_fulfill_is_called(h: TestHelper, s: String): Promise[String] =>
h.fail("fulfill shouldn't have been run on _FlattenNextFirstPromise")
Promise[String]

fun reject_expected(h: TestHelper, action: String): Promise[String] ? =>
h.complete_action(action)
error

fun fail_if_reject_is_called(h: TestHelper): Promise[String] =>
h.fail("reject shouldn't have been run on _FlattenNextFirstPromise")
Promise[String]

primitive _FlattenNextSecondPromise
"""
Callback functions called after fulfilled/reject is called on the second
promise in our `flatten_next` tests.

This helper is designed for tests where there are only two promises. there
are calls to `complete` on `TestHelper` that would be incorrectly placed if
additional promises were chained after this. If tests are added with longer
chains, this helper's usage of `complete` should be reworked accordingly.
"""
fun successful_fulfill(h: TestHelper, expected: String, actual: String) =>
h.assert_eq[String](expected, actual)
h.expect_action(expected)
h.complete(true)

fun fulfill_will_error(h: TestHelper, expected: String, actual: String) ? =>
h.assert_eq[String](expected, actual)
h.complete_action(expected)
h.complete(true)
error

fun fail_if_fulfill_is_called(h: TestHelper, s: String) =>
h.fail("fulfill shouldn't have been run on _FlattenNextSecondPromise")

fun reject_expected(h: TestHelper, action: String) =>
h.complete_action(action)
h.complete(true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly this can complete too early e.g.at line 238

Suggested change
h.complete(true)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, there are two reject_expected. One for Start and one for Inter. This is the one for Inter and the test is complete when reject_expected is called. There is also a reject_expected on the Start primitive that doesn't complete.

Note there are no complete calls on _FlattenNextStartHelper


fun fail_if_reject_is_called(h: TestHelper) =>
h.fail("reject shouldn't have been run on _FlattenNextSecondPromise")

Loading