rb612
rb612

Reputation: 5573

Ramda pipe conditionally?

I have a filter function which has essentially multiple variables that determine filtering logic. If the variable is defined, I want to filter—if not, I don't want to filter (i.e. execute the function in the pipeline). More generally, having a predicate that I can check on each argument to the pipe to determine if I should call it or just pass on to the next function.

I'm doing this to prevent complex branching logic, but being pretty new to functional programming, I thought this would be the best way to refactor.

For example:

resources = R.pipe(
   filterWithRadius(lat, lng, radius), // if any of these arguments are nil, act like R.identity
   filterWithOptions(filterOptions)(keyword), // if either filterOptions or keyword is nil, act like R.identity
   filterWithOptions(tagOptions)(tag) // same as above.
)(resources);

I was looking into using R.unless/R.when but it doesn't seem to work with functions of more than one argument. R.pipeWith would be useful here if it dealt with the function arguments instead.

As an example implementation:

const filterWithRadius = R.curry((lat, long, radius, resources) =>
  R.pipe(
    filterByDistance(lat, long, radius), // simply filters down a geographic location, will fail if any of lat/long/radius are not defined
    R.map(addDistanceToObject(lat, long)), // adds distance to the lat and long to prop distanceFromCenter
    R.sortBy(R.prop("distanceFromCenter")) // sorts by distance
  )(resources)
);

resources is an array of these resource objects. In essence, each of the functions, filterRadius and filterOptions are pure functions expecting an array of resources and valid arguments (not undefined) and output a new, filtered list. So the goal here is to somehow compose (or refactor) such that if the parameters are all undefined, it will run the function, else just act as the identity.

Is there a cleaner/better way than this?

resources = R.pipe(
   lat && lng && radius
     ? filterWithRadius(lat, lng, radius)
     : R.identity,
   keyword ? filterWithOptions(filterOptions)(keyword) : R.identity,
   tag ? filterWithOptions(tagOptions)(tag) : R.identity
)(resources);

Upvotes: 4

Views: 3219

Answers (4)

Hitmands
Hitmands

Reputation: 14199

Is there a cleaner/better way than this?

R.pipe(
   lat && lng && radius
     ? filterWithRadius(lat, lng, radius)
     : R.identity,
   keyword ? filterWithOptions(filterOptions)(keyword) : R.identity,
   tag ? filterWithOptions(tagOptions)(tag) : R.identity
)

It looks like every function can be applied if their arguments call are not-nil. Given that, it could be up the the filter functions to decide whether to apply the filtering or not.

This pattern is called call guard, where basically the first instructions of the function's body are used to guard the function application from any unusable value.

const filterWithRadius = (lat, lng, radius) => {
  if (!lat || !lng || !radius) {
    return R.identity;
  }
  
  return R.filter((item) => 'doSomething');
}


const foo = R.pipe(
  filterWithRadius(5, 1, 60),
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.min.js" integrity="sha256-43x9r7YRdZpZqTjDT5E0Vfrxn1ajIZLyYWtfAXsargA=" crossorigin="anonymous"></script>

you could do a more intensive use of ramda by:

const filterWithFoo = R.unless(
  (a, b, c) => R.isNil(a) || R.isNil(b) || R.isNil(c),
  R.filter(...)
);

Upvotes: 1

Guest
Guest

Reputation: 11

I just want to point out this anti-pattern:

// inline use of R.pipe
someVar = R.pipe(...)(someVar)

Not only is this a mutation of someVar which goes against a fundamental principle of functional programming, but it's also a misuse of R.pipe, which is intended to create a new function, such as -

const someProcess = R.pipe(...)

const someNewVar = someProcess(someVar)

I understand you're using R.pipe in order to make the code read nicer and flow from top-to-bottom, but your specific use is counterproductive. There's no reason to create an intermediate function if you're going to dispose of it right away.

Consider representing your intentions in a more straightforward manner -

const output =
  $ ( input                                      // starting with input,
    , filterWithRadius (lat, lng, radius)        // filterWithRadius then,
    , filterWithOptions (filterOptions, keyword) // filterWithOptions then,
    , filterWithOptions (tagOptions, tag)        // filterWithOptions then,
    , // ...                                     // ...
    )

Just as a carpenter crafts jigs and templates specific to his/her project, it's the programmer's job to invent any utility that makes his/her job easier. All you need to make this possible is an intelligent $. Here's a complete example -

const $ = (input, ...operations) =>
  operations .reduce (R.applyTo, input)
  
const add1 = x =>
  x + 1
  
const square = x =>
  x * x
 
const result =
  $ ( 10     // input of 10
    , add1   // 10 + 1 = 11
    , add1   // 11 + 1 = 12
    , square // 12 * 12 = 144
    )
    
console .log (result) // 144
<script src="https://unpkg.com/[email protected]/dist/ramda.min.js"></script>

Your program is not limited to three (3) operations. We can chain thousands without worry -

$ (2, square, square, square, square, square)
// => 4294967296

As for making functions behave like R.identity when certain arguments are nil (undefined), I would suggest using default arguments as a best practice -

const filterWithRadius = (lat = 0, lng = 0, radius = 0, input) =>
  // ...

Now if lat and lng are undefined, 0 will be supplied instead, which is a valid location known as the Prime Meridian. A search radius of 0 however should return no results. So we can finish our function easily -

const filterWithRadius = (lat = 0, lng = 0, radius = 0, input) =>
  radius <= 0 // if radius is less than or equal to 0,
    ? input   // return the input, unmodified
    : ...     // otherwise perform the filter using lat, lng, and radius

This makes filterWithRadius more robust without introducing null-check complexities. This is a clear win because the function is more self-documenting, produces a valid result in more cases, and doesn't involve writing more code to "fix" problems.


I see you used the inline R.pipe anti-pattern in your filterWithRadius function too. We could use $ to help us again here -

const filterWithRadius = (lat = 0, lng = 0, radius = 0, input) =>
  radius <= 0
    ? input
    : $ ( input
        , filterByDistance (lat, lng, radius)
        , map (addDistanceToObject (lat, lng))
        , sortBy (prop ("distanceFromCenter"))
        )

I hope this opens your eyes to some of the possibilities that are available to you.

Upvotes: 1

Ori Drori
Ori Drori

Reputation: 192317

For this solution to work, all your functions need to be curried, with the resources as the final param.

The is to create a function (passIfNil) that takes a function (fn), and the params (in the right order for fn). If any of this params are nil, R.identity is returned. If not, the original fn, but with the args applied to it is returned.

Example (not tested):

const passIfNil = (fn, ...args) => R.ifElse(
  R.any(R.isNil),
  R.always(R.identity),
  R.always(fn(...args))
);

resources = R.pipe(
   passIfNil(filterWithRadius, lat, lng, radius), // if any of these arguments are nil, act like R.identity
   passIfNil(filterWithOptions, filterOptions, keyword), // if either filterOptions or keyword is nil, act like R.identity
   passIfNil(filterWithOptions, tagOptions, tag) // same as above.
)(resources);

Upvotes: 1

Scott Sauyet
Scott Sauyet

Reputation: 50807

I think that you're looking to put the responsibility for this behavior in the wrong place. If you want your pipeline functions to have one behavior with certain data, and different behavior with other data (or in this case, with missing data), then those individual functions should handle it, and not the pipeline function that wraps them.

But, as Ori Drori pointed out, you can write a function decorator to make this happen.

Here's one suggestion:

// Dummy implementations
const filterWithRadius = (lat, lng, radius, resources) =>
  ({...resources, radiusFilter: `${lat}-${lng}-${radius}`})
const filterWithOptions = (opts, val, resources) => 
  ({...resources, [`optsFilter-${opts}`]: val})

// Test function (to be used in pipelines, but more general)
const ifNonNil = (fn) => (...args) => any(isNil, args) 
  ? identity 
  : (data) => fn (...[...args, data])
  // alternately, for variadic result : (...newArgs) => fn (...[...args, ...newArgs])

// Pipeline call
const getUpdatedResources = (
  {lat, lng, radius, filterOptions, keyword, tagOptions, tag}
) => pipe (
  ifNonNil (filterWithRadius) (lat, lng, radius),
  ifNonNil (filterWithOptions) (filterOptions, keyword),
  ifNonNil (filterWithOptions) (tagOptions, tag)
)

// Test data
const resources = {foo: 'bar'}

const query1 = {
   lat: 48.8584, lng: 2.2945, radius: 10, 
   filterOptions: 'baz', keyword: 'qux',
   tagOptions: 'grault', tag: 'corge'
}

const query2 = {
   lat: 48.8584, lng: 2.2945, radius: 10, 
   tagOptions: 'grault', tag: 'corge'
}

const query3 = {
   lat: 48.8584, lng: 2.2945, radius: 10, 
   filterOptions: 'baz', keyword: 'qux',
}

const query4 = {
   filterOptions: 'baz', keyword: 'qux',
   tagOptions: 'grault', tag: 'corge'
}

const query5 = {
   lat: 48.8584/*, lng: 2.2945*/, radius: 10, 
   filterOptions: 'baz', keyword: 'qux',
   tagOptions: 'grault', tag: 'corge'
}

const query6 = {}

// Demo
console .log (getUpdatedResources (query1) (resources))
console .log (getUpdatedResources (query2) (resources))
console .log (getUpdatedResources (query3) (resources))
console .log (getUpdatedResources (query4) (resources))
console .log (getUpdatedResources (query5) (resources))
console .log (getUpdatedResources (query6) (resources))
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script>
<script> const {any, isNil, pipe, identity} = R </script>

We start with dummy implementations of your filter* functions, ones which simply add a property to the input object.

The important function here is ifNotNil. It takes a function of n arguments, returning a function of n - 1 arguments that when called, checks if any of those arguments is nil. If any are, it returns the identity function; otherwise it returns a function of one argument, which in turns calls the original function with the n - 1 arguments and this latest one.

We use this to build a pipeline which will be returned from a function which accepts the variables required (here destructured naively from a potential query object.) This function is called by passing the query and then the actual data to be transformed.

The examples show various combinations of parameters included and excluded.

This makes the assumption that your functions are not curried, that, say, filterWithRadius looks like (lat, lng, radius, resources) => ... If they are curried, we might write this instead:

const ifNonNil = (fn) => (...args) => any(isNil, args) 
  ? identity 
  : reduce ((f, arg) => f(arg), fn, args)

used with

const filterWithRadius = (lat) => (lng) => (radius) => (resources) => 
  ({...resources, radiusFilter: `${lat}-${lng}-${radius}`})

but still called in the pipeline as

pipe (
  ifNonNil (filterWithRadius) (lat, lng, radius),
  // ...
)

You could even mix and match the curried and non-curried versions in the same pipeline, although I would expect that to add confusion.

Upvotes: 1

Related Questions