weavejester / medley

A lightweight library of useful Clojure functions
Eclipse Public License 1.0
870 stars 67 forks source link

Suggested addition: update-items #63

Open snoe opened 2 years ago

snoe commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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

weavejester commented 2 years ago

What about flop? 😃

NoahTheDuke commented 2 years ago

:shipit:

weavejester commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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.