Search code examples
clojuremacrosclojure-java-interop

Macro To Read Field Names and set to value from other object


I am new to clojure and am trying to refactor some code that I've written that looks like this

(defn transform
  [entity]
  (let [new-obj (doto (SomeObj.)
                (.setField1 (:field-1 entity)))
  new-obj))

I have many objects that will need this implemented but would like to create a macro that accepts an entity, prototype Ex: (SomeObj.), and a map where the keys are the field names of the prototype and the values are vectors of keywords to get the correct field from the entity. For each key I would need to call .set + keyName using the argument from (get-in map [value as a vector]).

My hope is for each new entity I can create a config of the mappings and only write code for special cases. Is this possible using a macro?

Or is there a more idiomatic way to do this in clojure.


Solution

  • yes, you could easily do it with a macro like this:

    (defmacro map-to [type mappings entity]
      `(doto (new ~type)
         ~@(map (fn [[field entity-field]]
                  `(~(symbol (str ".set" (clojure.string/capitalize field)))
                    (~entity-field ~entity)))
                mappings)))
    

    this would generate exactly the code you need:

    (map-to java.util.Date {date :dt minutes :mm hours :h}
            {:dt 10 :mm 22 :h 12})
    

    would be expanded into the following:

    (doto
      (new java.util.Date)
      (.setDate (:dt {:dt 10, :mm 22, :h 12}))
      (.setMinutes (:mm {:dt 10, :mm 22, :h 12}))
      (.setHours (:h {:dt 10, :mm 22, :h 12})))
    

    a few things to notice here:

    1) you don't need to introduce a new variable new-obj, since doto returns the object being operated on.

    2) your mappings should be passed as a literal map, because otherwise you cannot get the keys to pass to . special form.

    3) you can see that the entity map is being repeated. You can fix this by introducing another binding inside the macro:

    (defmacro map-to [type mappings entity]
      (let [ent (gensym "entity")]
        `(let [~ent ~entity]
           (doto (new ~type)
             ~@(map (fn [[field entity-field]]
                      `(~(symbol (str ".set" (clojure.string/capitalize field)))
                        (~entity-field ~ent)))
                    mappings)))))
    

    so now it expands like this:

    (let [entity20047 {:dt 10, :mm 22, :h 12}]
      (doto
        (new java.util.Date)
        (.setDate (:dt entity20047))
        (.setMinutes (:mm entity20047))
        (.setHours (:h entity20047))))
    

    in repl:

    user> (map-to java.util.Date {date :dt minutes :mm hours :h}
                  {:dt 10 :mm 22 :h 12})
    
    ;;=> #inst "2016-09-10T09:22:48.867-00:00"
    
    user> (let [ent {:dt 10 :mm 22 :h 12}]
            (map-to java.util.Date {date :dt minutes :mm hours :h} ent))
    
    ;;=> #inst "2016-09-10T09:22:48.899-00:00"
    

    (the value is three hours earlier due to my time zone (gmt+3))

    update

    to get your desired behaviour (with get-in) you can just slightly modify this macro:

    (defmacro map-to [type mappings entity]
      (let [ent (gensym "entity")]
        `(let [~ent ~entity]
           (doto (new ~type)
             ~@(map (fn [[field entity-field]]
                      `(~(symbol (str ".set" (clojure.string/capitalize field)))
                        (get-in ~ent ~entity-field)))
                    mappings)))))
    

    in repl:

    user> (map-to java.util.Date {date [:date :dt]
                                  minutes [:time :mm]
                                  hours [:time :h]}
                  {:date {:dt 10} :time {:mm 22 :h 12}})
    
    ;;=> #inst "2016-09-10T09:22:41.935-00:00"
    

    expands to:

    (let [entity20094 {:date {:dt 10}, :time {:mm 22, :h 12}}]
      (doto
        (new java.util.Date)
        (.setDate (get-in entity20094 [:date :dt]))
        (.setMinutes (get-in entity20094 [:time :mm]))
        (.setHours (get-in entity20094 [:time :h]))))
    

    now you can make one more macro to automate the creation of mapping functions:

    first of all you need a function to produce maker name from class object:

    (defn make-name [c]
      (->> c
           .getName
           (#(clojure.string/split % #"\."))
           (clojure.string/join "-")
           (str "create-")
           symbol))
    
    user> (make-name java.util.Date)
    ;;=> create-java-util-Date
    

    now the macro to define functions to create instances from entities:

    (defmacro defmapper [type mappings]
      `(defn ~(make-name type) [entity#]
         (map-to ~type ~mappings entity#)))
    

    this one would create functions, that are, given an entity, convert it to the class instance. It's just an ordinary function:

    (defmapper java.util.Date {date [:date :dt]
                               minutes [:time :mm]
                               hours [:time :h]})
    

    expands to:

    (defn create-java-util-Date [entity__20122__auto__]
      (map-to
        java.util.Date
        {date [:date :dt], minutes [:time :mm], hours [:time :h]}
        entity__20122__auto__))
    

    in repl:

    user> (map create-java-util-Date
               [{:date {:dt 10} :time {:mm 22 :h 12}}
                {:date {:dt 11} :time {:mm 22 :h 12}}
                {:date {:dt 12} :time {:mm 22 :h 12}}])
    
    ;;(#inst "2016-09-10T09:22:18.974-00:00" 
    ;; #inst "2016-09-11T09:22:18.974-00:00" 
    ;; #inst "2016-09-12T09:22:18.974-00:00")