clojurehashmapdestructuring

Filtering a map based on expected keys


In my Clojure webapp I have various model namespaces with functions that take a map as an agrument and somehow insert that map into a database. I would like to be able take out only the desired keys from the map before I do the insert.

A basic example of this is:

(let [msg-keys [:title :body]
      msg {:title "Hello" :body "This is an example" :somekey "asdf" :someotherkey "asdf"}]
  (select-keys msg msg-keys))

;; => {:title "Hello" :body "This is an example"}

select-keys is not an option when the map is somewhat complex and I would like to select a specific set of nested keys:

(let [person {:name {:first "john" :john "smith"} :age 40 :weight 155 :something {:a "a" :b "b" :c "c" :d "d"}}]
  (some-select-key-fn person [:name [:first] :something [:a :b]]))

;; => {:name {:first "john"} :something {:a "a" :b "b"}}

Is there a way to do this with the core functions? Is there a way do this purely with destructuring?


Solution

  • This topic was discussed in the Clojure Google Group along with a few solutions.

    Destructuring is probably the closest to a "core" capability, and may be a fine solution if your problem is rather static and the map has all of the expected keys (thus avoiding nil). It could look like:

    (let [person {:name {:first "john" :john "smith"} :age 40 :weight 155 :something {:a "a" :b "b" :c "c" :d "d"}}
          {{:keys [first]} :name {:keys [a b]} :something} person]
      {:name {:first first} :something {:a a :b b}})
    ;; => {:name {:first "john"}, :something {:a "a", :b "b"}}
    

    Below is a survey of the solutions in the Clojure Google Group thread, applied to your sample map. They each have a different take on how to specify the nested keys to be selected.

    Here is Christophe Grand's solution:

    (defprotocol Selector
      (-select [s m]))
    
    (defn select [m selectors-coll]
      (reduce conj {} (map #(-select % m) selectors-coll)))
    
    (extend-protocol Selector
      clojure.lang.Keyword
      (-select [k m]
        (find m k))
      clojure.lang.APersistentMap
      (-select [sm m]
        (into {}
              (for [[k s] sm]
                [k (select (get m k) s)]))))
    

    Using it requires a slightly modified syntax:

    (let [person {:name {:first "john" :john "smith"} :age 40 :weight 155 :something {:a "a" :b "b" :c "c" :d "d"}}]
      (select person [{:name [:first] :something [:a :b]}]))
    ;; => {:something {:b "b", :a "a"}, :name {:first "john"}}
    

    Here is Moritz Ulrich's solution (he cautions that it doesn't work on maps with seqs as keys):

    (defn select-in [m keyseq]
      (loop [acc {} [k & ks] (seq keyseq)]
        (if k
          (recur
            (if (sequential? k)
              (let [[k ks] k]
                (assoc acc k
                       (select-in (get m k) ks)))
              (assoc acc k (get m k)))
            ks)
          acc)))
    

    Using it requires another slightly modified syntax:

    (let [person {:name {:first "john" :john "smith"} :age 40 :weight 155 :something {:a "a" :b "b" :c "c" :d "d"}}]
      (select-in person [[:name [:first]] [:something [:a :b]]]))
    ;; => {:something {:b "b", :a "a"}, :name {:first "john"}}
    

    Here is Jay Fields's solution:

    (defn select-nested-keys [m top-level-keys & {:as pairs}]
      (reduce #(update-in %1 (first %2) select-keys (last %2)) (select-keys m top-level-keys) pairs))
    

    It uses a different syntax:

    (let [person {:name {:first "john" :john "smith"} :age 40 :weight 155 :something {:a "a" :b "b" :c "c" :d "d"}}]
      (select-nested-keys person [:name :something] [:name] [:first] [:something] [:a :b]))
    ;; => {:something {:b "b", :a "a"}, :name {:first "john"}}
    

    Here is Baishampayan Ghose's solution:

    (defprotocol ^:private IExpandable
                 (^:private expand [this]))
    
    
    (extend-protocol IExpandable
      clojure.lang.Keyword
      (expand [k] {k ::all})
    
      clojure.lang.IPersistentVector
      (expand [v] (if (empty? v)
                    {}
                    (apply merge (map expand v))))
    
      clojure.lang.IPersistentMap
      (expand [m]
        (assert (= (count (keys m)) 1) "Number of keys in a selector map can't be more than 1.")
        (let [[k v] (-> m first ((juxt key val)))]
              {k (expand v)}))
    
      nil
      (expand [_] {}))
    
    
    (defn ^:private extract* [m selectors expand?]
      (let [sels (if expand? (expand selectors) selectors)]
        (reduce-kv (fn [res k v]
                     (if (= v ::all)
                       (assoc res k (m k))
                       (assoc res k (extract* (m k) v false))))
                   {} sels)))
    
    (defn extract
      "Like select-keys, but can select nested keys.
    
       Examples -
    
       (extract [{:b {:c [:d]}} :g] {:a 1 :b {:c {:d 1 :e 2}} :g 42 :xxx 11})
       ;=> {:g 42, :b {:c {:d 1}}}
    
       (extract [:g] {:a 1 :b {:c {:d 1 :e 2}} :g 42 :xxx 11})
       ;=> {:g 42}
    
       (extract [{:b [:c]} :xxx] {:a 1 :b {:c {:d 1 :e 2}} :g 42 :xxx 11})
       ;=> {:b {:c {:d 1, :e 2}}, :xxx 11}
    
       Also see - exclude"
      [selectors m]
      (extract* m selectors true))
    

    It uses another syntax (and the parameters are reversed):

    (let [person {:name {:first "john" :john "smith"} :age 40 :weight 155 :something {:a "a" :b "b" :c "c" :d "d"}}]
      (extract [{:name [:first]} {:something [:a :b]}] person))
    ;; => {:name {:first "john"}, :something {:a "a", :b "b"}}