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

Suggested addition: update-items #63

Open
snoe opened this issue Apr 28, 2022 · 9 comments
Open

Suggested addition: update-items #63

snoe opened this issue Apr 28, 2022 · 9 comments

Comments

@snoe
Copy link

snoe commented Apr 28, 2022

First off, this might be out of scope for the project, but I do think it is a powerful addition and still fits under pure, and general purpose. Second, I'm not sure if my implementation is the most performant, nor if the name is the best.

Rationale

It is very common to find highly nested data-structures.
This is why get-in, assoc-in, and update-in exist.

Unfortunately, these fall apart if you need to process items of a collection within the structure. Even worse is if you have a multiple collections to in the tree. I believe this difficulty was a major motivator for large DSLs like meander or spectre.

I've been using this function for a number of years and find it invaluable when I know the shape of a data-structure but need to traverse collections and update-in is insufficient.

Comparison

Here's an example, if I have a map with users, that have orders, that have items, that each have a price

{:users [{:id 1 :orders #{{:items [{:price 1} {:price 4} {:price 2}]}}}
         {:id 2 :orders #{}}]}

Suppose you want to change all the item prices to strings with a dollar sign pre-pended. You'd need to do something like:

(update
  {:users [{:id 1 :orders #{{:items [{:price 1} {:price 4} {:price 2}]}}}
           {:id 2 :orders #{}}]}
  :users
  (fn [users]
    (mapv (fn [user]
            (update user :orders (fn [orders]
                                   (set (map (fn [order]
                                               (update order :items (fn [items]
                                                                      (mapv (fn [item]
                                                                              (update item :price #(str "$" %)))
                                                                            items))))
                                             orders)))))
          users)))
;; =>
{:users [{:id 1, :orders #{{:items [{:price "$1"} {:price "$4"} {:price "$2"}]}}} 
         {:id 2, :orders #{}}]}          

Writing this example out, I made a number of mistakes: I tried to nest function literals, I forgot to pass the coll to many of the map calls, I forgot that :orders is supposed to be a set.

Here's the signature for update-items, similar to update but coll-k points at a collection and item-update-fn is applied to each item in the collection.

(defn update-items [m coll-k item-update-fn & item-update-args])

Using this, our highly nested, tedious, and error-prone processing can flatten out completely.

(update-items
  {:users [{:id 1 :orders #{{:items [{:price 1} {:price 4} {:price 2}]}}}
           {:id 2 :orders #{}}]}
  :users
  update-items
  :orders
  update-items
  :items
  update
  :price
  #(str "$" %))
;; =>
{:users [{:id 1, :orders #{{:items [{:price "$1"} {:price "$4"} {:price "$2"}]}}} 
         {:id 2, :orders #{}}]}          

POC Implementation

(defn update-items*
  [m k mapping-fn item-update-fn & item-update-args]
  (update m k
          (fn [coll]
            (mapping-fn (fn [item]
                          (apply item-update-fn item item-update-args))
                        coll))))

(defn update-items
  [m k item-update-fn & item-update-args]
  (apply update-items*
         m
         k
         (fn [mapper coll]
           (into (empty coll) (map mapper) coll))
         item-update-fn
         item-update-args))

This could be written without update-items* but being able to pass the mapping-fn can be useful:

(update-items*
  {:users [{:id 1 :orders #{{:items [{:price 1} {:price 4} {:price 2}]}}}
           {:id 2 :orders #{}}]}
  :users
  (comp #(filterv (comp seq :orders) %) map)
  update-items
  :orders
  update-items*
  :items
  (comp vec #(sort-by :price %) filterv)
  (comp even? :price))
;; =>
{:users [{:id 1, :orders #{{:items [{:price 2} {:price 4}]}}}]}
@weavejester
Copy link
Owner

What about a more general solution:

(defn flip [f]
  (fn [coll g & args]
    (f #(apply g % args) coll)))

It might need a better name, but it changes a collection function like map to work more like update. So:

;; original
(map #(update % :price inc) items)

;; flipped
((flip mapv) items update :price inc)

This allows us to write:

(update m
  :users
  (flip mapv)
  update :orders
  (flip (comp set map))
  update :items
  (flip mapv)
  update :price #(str "$" %))

And:

(update m
  :users
  (flip (comp #(filterv (comp seq :orders) %) mapv))
  update :orders
  (flip (comp set map))
  update :items
  (flip filterv)
  (comp even? :price))

@snoe
Copy link
Author

snoe commented Apr 28, 2022

I like it, flip also probably has more precedence ( https://lodash.com/docs/#flip ), although I like that your version moves coll position rather than reversing args.

@NoahTheDuke
Copy link

NoahTheDuke commented Apr 28, 2022

I would recommend against the name flip for that reason, but it's a very clever function that I would also use.

@weavejester
Copy link
Owner

What about flop? 😃

@NoahTheDuke
Copy link

:shipit:

@weavejester
Copy link
Owner

After having thought about it a while, my current inclination is to use tilt:

(defn tilt
  "Converts a collection function of 2 arguments, such as map or filter, into a
  function with an argument signature that can be used with update or swap!.

  For a collection function collf, the expression ((tilt collf) coll f & args)
  is equivalent to: (collf #(apply f % args) coll)."
  [collf]
  (fn [coll f & args]
    (collf #(apply f % args) coll)))

@maxrothman
Copy link

Just throwing in my 2c, I've been calling this fn to-> because it can be used to adapt thread-last-style fns (e.g. map) to thread-first style:

(defn to->
  "Adapt f to thread-first"
  [x f & args]
  (apply f (concat args (list x))))

It turns out that this fn is exactly equivalent to ->> except that it's a fn rather than a macro so it can be passed to higher-order functions.

Accordingly, I also have a thread-last version:

(defn to->>
  "Adapt f to thread-last"
  [f & args]
  (let [x (last args)]
    (apply f x (butlast args))))

@weavejester
Copy link
Owner

weavejester commented Jul 14, 2022

Unless I'm mistaken, @maxrothman, your to-> and to->> functions are different in purpose from tilt, and don't quite solve the same problem.

@maxrothman
Copy link

You're right, upon closer inspection, to-> only allows you to traverse one level of nesting. For example, (update {:a [1 2 3]} :a to-> map inc) works fine, but (update {:a [{:x 1}]} :a to-> map update :x inc does not.

I wonder if there's a way to have the same fn/macro work for both update-like usecases and threading usecases. I suppose with tilt you could always do (-> [1 2 3] ((tilt map) inc)), but that feels a little ugly. Maybe multiple arities? Maybe the problem is that map treats additional args as additional collections, rather than as extra static args to the mapping fn like update does? I'll think on it more.

Mostly I wanted to point out that adapting fns to thread-first/last is an adjacent problem, and that there might be utility in having the opposite-threading version as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants