Skip to content

Commit

Permalink
Merge pull request #411 from smartprocure/flowAsync
Browse files Browse the repository at this point in the history
`flowAsync`
  • Loading branch information
daedalus28 committed Mar 26, 2023
2 parents 2e265e6 + 093549d commit 8ac68f9
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 10 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# 1.74.0

- Add `flowAsync` and `promiseProps`
- Add `flowAsyncDeep` and `resolveOnTree`
- Pass along all tree iteratee props in `writeTreeNode` (default writeNode for tree maps) and rename interally from writeProperty to writeTreeNode

# 1.73.3

- Generate readme in CI
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ or

# API

## Async

### promiseProps

`{ a: Promise, b: Promise} => Promise<{a: value, b: value}>`
Like `Promise.all`, but for objects. Polyfill bluebird Promise.props. Takes an object with promise values and returns a promise that resolves with an object with resolved values instead.

### flowAsync

`(f1, f2, ...fn) -> (...args) => fn(f2(f1(...args)))`
Like `_.flow`, but supports flowing together async and non async methods.
If nothing is async, it _stays synchronous_.
Also, it handles awaiting arrays of promises (e.g. from _.map) with `Promise.all` and objects of promises (e.g. from _.mapValues) with `promiseProps`.
This method generally solves most issues with using futil/lodash methods asynchronously. It's like magic!
NOTE: Main gotchas are methods that require early exit like `find` which can't be automatically async-ified. Also does not handle promises for keys.
Use `F.resolveOnTree` to await more complexly nested promises.

### flowAsyncDeep

`(f1, f2, ...fn) -> (...args) => fn(f2(f1(...args)))`
Just like `F.flowAsync`, except it recurses through return values using `F.resolveOnTree` instead of just `Promise.all` or `promise.props`
_CAUTION_ Just like `resolveOnTree`, this will mutate intermediate results to resolve promises. This is generally safe (and more performant) but might not always be what you expect.

## Function

### maybeCall
Expand Down Expand Up @@ -1249,8 +1272,15 @@ Creates a path builder for use in `flattenTree`, using a slashEncoder and using
`traverse -> buildPath -> tree -> result`
Creates a flat object with a property for each node, using `buildPath` to determine the keys. `buildPath` takes the same arguments as a tree walking iteratee. It will default to a dot tree path.
### resolveOnTree
`(traverse, writeNode) -> tree -> result`
Resolves all Promise nodes of a tree and replaces them with the result of calling `.then`
Exposed on `F.tree` as `resolveOn`
_CAUTION_ This method mutates the tree passed in. This is generally safe and more performant (and can be intuited from the `On` convention in the name), but it's worth calling out.
### tree
`(traverse, buildIteratee, writeNode) -> {walk, reduce, transform, toArray, toArrayBy, leaves, leavesBy, map, mapLeaves, lookup, keyByWith, traverse, flatten, flatLeaves }`
`(traverse, buildIteratee, writeNode) -> { walk, walkAsync, transform, reduce, toArrayBy, toArray, leaves, leavesBy, lookup, keyByWith, traverse, flatten, flatLeaves, map, mapLeaves, resolveOn }`
Takes a traversal function and returns an object with all of the tree methods pre-applied with the traversal. This is useful if you want to use a few of the tree methods with a custom traversal and can provides a slightly nicer api.
Exposes provided `traverse` function as `traverse`
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "futil-js",
"version": "1.73.3",
"version": "1.74.0",
"description": "F(unctional) util(ities). Resistance is futile.",
"main": "lib/futil-js.js",
"scripts": {
Expand Down
51 changes: 51 additions & 0 deletions src/async.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import _ from 'lodash/fp'
import { resolveOnTree } from './tree'

/**
* Like `Promise.all`, but for objects. Polyfill bluebird Promise.props. Takes an object with promise values and returns a promise that resolves with an object with resolved values instead.
*
* @signature { a: Promise, b: Promise} => Promise<{a: value, b: value}>
*/
export let promiseProps =
Promise.props ||
(async (x) => _.zipObject(_.keys(x), await Promise.all(_.values(x))))

// Calls then conditionally, allowing flow to be used synchronously, too
let asyncCall = (value, f) => (value.then ? value.then(f) : f(value))
let asyncCallShallow = (prop, f) => {
if (_.some('then', prop)) {
if (_.isArray(prop)) return Promise.all(prop).then(f)
if (_.isPlainObject(prop)) return promiseProps(prop).then(f)
}
return asyncCall(prop, f)
}
let asyncCallDeep = (prop, f) => {
prop = resolveOnTree()(prop)
return asyncCall(prop, f)
}
// This implementation of `flow` spreads args to the first method and takes a method to determine how to combine function calls
let flowWith =
(call) =>
(fn0, ...fns) =>
(...x) =>
[...fns, (x) => x].reduce(call, fn0(...x))

/**
* Like `_.flow`, but supports flowing together async and non async methods.
* If nothing is async, it *stays synchronous*.
* Also, it handles awaiting arrays of promises (e.g. from _.map) with `Promise.all` and objects of promises (e.g. from _.mapValues) with `promiseProps`.
* This method generally solves most issues with using futil/lodash methods asynchronously. It's like magic!
* NOTE: Main gotchas are methods that require early exit like `find` which can't be automatically async-ified. Also does not handle promises for keys.
* Use `F.resolveOnTree` to await more complexly nested promises.
*
* @signature (f1, f2, ...fn) -> (...args) => fn(f2(f1(...args)))
*/
export let flowAsync = flowWith(asyncCallShallow)

/**
* Just like `F.flowAsync`, except it recurses through return values using `F.resolveOnTree` instead of just `Promise.all` or `promise.props`
* _CAUTION_ Just like `resolveOnTree`, this will mutate intermediate results to resolve promises. This is generally safe (and more performant) but might not always be what you expect.
*
* @signature (f1, f2, ...fn) -> (...args) => fn(f2(f1(...args)))
*/
export let flowAsyncDeep = flowWith(asyncCallDeep)
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import _ from 'lodash/fp'

export * from './async'
export * from './conversion'
export * from './collection'
export * from './function'
Expand All @@ -14,6 +15,7 @@ export * from './lens'
export * from './tree'
export * from './iterators'

import * as async from './async'
import * as conversion from './conversion'
import * as collection from './collection'
import * as fn from './function'
Expand Down Expand Up @@ -60,6 +62,7 @@ export const VERSION = global.__VERSION__

// Allows `import F from 'futil-js'`
export default {
...async,
...conversion,
...collection,
...fn,
Expand Down
35 changes: 28 additions & 7 deletions src/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,18 @@ export let reduceTree = (next = traverse) =>
return result
})

let writeProperty =
let writeTreeNode =
(next = traverse) =>
(node, index, [parent]) => {
next(parent)[index] = node
(node, index, [parent, ...parents], [parentIndex, ...indexes]) => {
next(parent, parentIndex, parents, indexes)[index] = node
}

/**
* Structure preserving tree map! `writeNode` informs how to write a single node, but the default will generally work for most cases. The iteratee is passed the standard `node, index, parents, parentIndexes` args and is expected to return a transformed node.
*
* @signature (traverse, writeNode) -> f -> tree -> newTree
*/
export let mapTree = (next = traverse, writeNode = writeProperty(next)) =>
export let mapTree = (next = traverse, writeNode = writeTreeNode(next)) =>
_.curry(
(mapper, tree) =>
transformTree(next)((node, i, parents, ...args) => {
Expand All @@ -131,7 +131,7 @@ export let mapTree = (next = traverse, writeNode = writeProperty(next)) =>
*
* @signature (traverse, writeNode) -> f -> tree -> newTree
*/
export let mapTreeLeaves = (next = traverse, writeNode = writeProperty(next)) =>
export let mapTreeLeaves = (next = traverse, writeNode = writeTreeNode(next)) =>
_.curry((mapper, tree) =>
// this unless wrapping can be done in user land, this is pure convenience
// mapTree(next, writeNode)(F.unless(next, mapper), tree)
Expand Down Expand Up @@ -262,16 +262,36 @@ export let flattenTree =

export let flatLeaves = (next = traverse) => _.reject(next)

/**
* Resolves all Promise nodes of a tree and replaces them with the result of calling `.then`
* Exposed on `F.tree` as `resolveOn`
* _CAUTION_ This method mutates the tree passed in. This is generally safe and more performant (and can be intuited from the `On` convention in the name), but it's worth calling out.
*
* @signature (traverse, writeNode) -> tree -> result
*/
export let resolveOnTree =
(next = traverse, writeNode = writeTreeNode(next)) =>
(tree) => {
let promises = []
walk(next)((node, ...args) => {
if (node.then)
// Mutates because `_.deepClone` on a tree of promises causes explosions
promises.push(node.then((newNode) => writeNode(newNode, ...args)))
})(tree)
// Dont return a promise if nothing was async
return _.isEmpty(promises) ? tree : Promise.all(promises).then(() => tree)
}

/**
* Takes a traversal function and returns an object with all of the tree methods pre-applied with the traversal. This is useful if you want to use a few of the tree methods with a custom traversal and can provides a slightly nicer api.
Exposes provided `traverse` function as `traverse`
*
* @signature (traverse, buildIteratee, writeNode) -> {walk, reduce, transform, toArray, toArrayBy, leaves, leavesBy, map, mapLeaves, lookup, keyByWith, traverse, flatten, flatLeaves }
* @signature (traverse, buildIteratee, writeNode) -> { walk, walkAsync, transform, reduce, toArrayBy, toArray, leaves, leavesBy, lookup, keyByWith, traverse, flatten, flatLeaves, map, mapLeaves, resolveOn }
*/
export let tree = (
next = traverse,
buildIteratee = _.identity,
writeNode = writeProperty(next)
writeNode = writeTreeNode(next)
) => ({
walk: walk(next),
walkAsync: walkAsync(next),
Expand All @@ -288,4 +308,5 @@ export let tree = (
flatLeaves: flatLeaves(next),
map: mapTree(next, writeNode),
mapLeaves: mapTreeLeaves(next, writeNode),
resolveOn: resolveOnTree(next, writeNode),
})
110 changes: 110 additions & 0 deletions test/async.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import _ from 'lodash/fp'
import chai from 'chai'
import Promise from 'bluebird'
import * as F from '../src'

chai.expect()
const expect = chai.expect

describe('Async Functions', () => {
it('promiseProps', async () => {
let slow = _.curryN(2, async (f, ...args) => {
await Promise.delay(10)
return f(...args)
})
let slowAdd = slow((x, y) => x + y)
let slowDouble = slow((x) => x * 2)
let x = {
a: slowAdd(3, 4),
b: slowDouble(5),
}
expect(x.a).to.not.equal(7)
expect(x.b).to.not.equal(10)
let y = await F.promiseProps(x)
expect(y.a).to.equal(7)
expect(y.b).to.equal(10)
})
it('flowAsync', async () => {
let slow = _.curryN(2, async (f, ...args) => {
await Promise.delay(10)
return f(...args)
})
let slowAdd = slow((x, y) => x + y)
let slowDouble = slow((x) => x * 2)
let add1 = slow((x) => x + 1)

// Mixes sync and async
let testMidAsync = F.flowAsync(_.get('a'), slowDouble, (x) => x * 3)
expect(await testMidAsync({ a: 4 })).to.equal(24)

// Stays sync when there's no async
let testSync = F.flowAsync(_.get('a'), (x) => x + 2)
expect(testSync({ a: 1 })).to.equal(3)

// Handles mixed sync/async for arrays
let testArrayAsync = F.flowAsync(
_.map(slowDouble),
_.map((x) => x * 2)
)
expect(await testArrayAsync([1, 2])).to.deep.equal([4, 8])

// Handles pure async
let f = F.flowAsync(slowAdd, slowDouble)
let result = await f(2, 3)
expect(result).to.equal(10)

// Doesn't handle promise keys because the key becomes a string
let testAsyncObj = F.flowAsync(
_.mapValues(slowDouble),
_.mapKeys(slowDouble)
)
expect(await testAsyncObj({ a: 1 })).to.deep.equal({
'[object Promise]': 2,
})

// Can be used as mapAsync
let mapAsync = F.flowAsync(_.map)
expect(await mapAsync(add1, [1, 2, 3])).to.deep.equal([2, 3, 4])
expect(await mapAsync(add1, { a: 1, b: 2, c: 3 })).to.deep.equal([2, 3, 4])

// Can be used as mapValuesAsync
let mapValuesAsync = F.flowAsync(_.mapValues)
expect(await mapValuesAsync(add1, { a: 1, b: 2, c: 3 })).to.deep.equal({
a: 2,
b: 3,
c: 4,
})
})
it('resolveOnTree', async () => {
let slow = _.curryN(2, async (f, ...args) => {
await Promise.delay(10)
return f(...args)
})
let slowAdd = slow((x, y) => x + y)
let slowDouble = slow((x) => x * 2)

let Tree = F.tree()
let tree = {
a: { b: slowAdd(1, 2) },
c: [slowAdd(3, 4), { d: 5, e: slowDouble(2) }],
}
let expected = { a: { b: 3 }, c: [7, { d: 5, e: 4 }] }
// Tree is resolved with Promises replaced with results
expect(await Tree.resolveOn(tree)).to.deep.equal(expected)
// Original tree is mutated
expect(tree).to.deep.equal(expected)
// No need to await when there are no promises, original tree is returned
expect(Tree.resolveOn(expected)).to.deep.equal(expected)
})
it('flowAsyncDeep', async () => {
let slow = _.curryN(2, async (f, ...args) => {
await Promise.delay(10)
return f(...args)
})
let add1 = slow((x) => x + 1)

expect(
await F.flowAsyncDeep(_.update('a.b', add1))({ a: { b: 1, c: 2 } })
).to.deep.equal({ a: { b: 2, c: 2 } })
})
})

0 comments on commit 8ac68f9

Please sign in to comment.