Ramda js: lens for deeply nested objects with nested arrays of objects
Asked Answered
I

2

20

Using Ramda.js (and lenses), I want to modify the JavaScript object below to change "NAME:VERSION1" to "NAME:VERSION2" for the object that has ID= "/1/B/i".

I want to use a lens because I want to just change one deeply nested value, but otherwise retain the entire structure unchanged.

I don't want to use lensIndex because I never know what order the arrays will be in, so instead, I want to "find" the object in an array by looking for its "id" fields.

Can I do this with lenses, or should I do it a different way?

{
  "id": "/1",
  "groups": [
    {
      "id": "/1/A",
      "apps": [
        {
          "id": "/1/A/i",
          "more nested data skipped to simplify the example": {} 
        }
      ]
    },
    {
      "id": "/1/B",
      "apps": [
        { "id": "/1/B/n", "container": {} },
        {
          "id": "/1/B/i",

          "container": {
            "docker": {
              "image": "NAME:VERSION1",
              "otherStuff": {}
            }
          }
        }
      ]
    }

  ]
}
Inflect answered 21/2, 2016 at 16:0 Comment(0)
T
28

This should be possible by creating a lens that matches an object by ID which can then be composed with other lenses to drill down to the image field.

To start with, we can create a lens that will focus on an element of an array that matches some predicate (note: this will only be a valid lens if it is guaranteed to match at least one element of the list)

//:: (a -> Boolean) -> Lens [a] a
const lensMatching = pred => (toF => entities => {
    const index = R.findIndex(pred, entities);
    return R.map(entity => R.update(index, entity, entities),
                 toF(entities[index]));
});

Note that we're manually constructing the lens here rather than using R.lens to save duplication of finding the index of the item that matches the predicate.

Once we have this function we can construct a lens that matches a given ID.

//:: String -> Lens [{ id: String }] { id: String }
const lensById = R.compose(lensMatching, R.propEq('id'))

And then we can compose all the lenses together to target the image field

const imageLens = R.compose(
  R.lensProp('groups'),
  lensById('/1/B'),
  R.lensProp('apps'),
  lensById('/1/B/i'),
  R.lensPath(['container', 'docker', 'image'])
)

Which can be used to update the data object like so:

set(imageLens, 'NAME:VERSION2', data)

You could then take this a step further if you wanted to and declare a lens that focuses on the version of the image string.

const vLens = R.lens(
  R.compose(R.nth(1), R.split(':')),
  (version, str) => R.replace(/:.*/, ':' + version, str)
)

set(vLens, 'v2', 'NAME:v1') // 'NAME:v2'

This could then be appended to the composition of imageLens to target the version within the entire object.

const verLens = compose(imageLens, vLens);
set(verLens, 'VERSION2', data);
Tropo answered 22/2, 2016 at 1:1 Comment(6)
This is really easy to understand, and can be easy composed and/or modified. Thank you! For lensMatching, could that be replaced by: function lensMatching(pred) { return R.lens( R.find(pred), (newVal, array, other) => { const index = R.findIndex(pred, array); return R.update(index, newVal, array); } ) } This seems a little easier for me to relate back to the lens documentation. But, am I missing something?Inflect
@GregEdwards That should work too. The main reason I suggested the other implementation was to prevent scanning the arrays twice (once in find and once in findIndex), however this shouldn't be a problem if the arrays are reasonably small.Tropo
Thank you for the solution, @ScottChristopher :) I am completely new to ramda, and functional programming at all, but isn't this a missing functionality in ramda? – to make a lens match by property value? I assume it is a quite common scenario, and would prefer to be able to just write the final compose-function directly as an array like this: const imageLens = R.lensPath(['groups', {id: '/1/B'}, 'apps', {id: '/1/B/i'}, 'container', 'docker', 'image'])Laird
@Laird There are plenty of lenses (among other functions) that can be constructed using the primitives offered by Ramda that aren't included in the library. It gets a little tricky with lenses like the one proposed because you need to ensure the lens will always match their focus, which can't be guaranteed by a general purpose library (e.g. what would it match for an empty array?)Tropo
@ScottChristopher OK, I thought that problem would occur with the included functions as well, like the valid (AFAIK) lensPath(['groups', 1, 'apps', 1, 'container', 'docker', 'image'])?Laird
@Laird You're right, functions like R.lensIndex fall into a similar category, though their usefulness was deemed to outweigh the potential risk of non-totality. If you feel similarly for this function then I'd suggest raising an issue or PR on the ramda/ramda github repo to propose the idea to the wider Ramda community.Tropo
C
10

Here's one solution:

const updateDockerImageName =
R.over(R.lensProp('groups'),
       R.map(R.over(R.lensProp('apps'),
                    R.map(R.when(R.propEq('id', '/1/B/i'),
                                 R.over(R.lensPath(['container', 'docker', 'image']),
                                        R.replace(/^NAME:VERSION1$/, 'NAME:VERSION2')))))));

This could be decomposed into smaller functions, of course. :)

Cathey answered 21/2, 2016 at 19:18 Comment(2)
Is there a way to not have the query so deeply nested? Using "compose" or...?Inflect
Thanks for your answer, which makes it more clear how to use over, map and when nicely.Inflect

© 2022 - 2024 — McMap. All rights reserved.