piotr-yuxuan / slava

Kafka Avro Serde for Clojure
https://github.com/piotr-yuxuan/slava
European Union Public License 1.2
6 stars 0 forks source link

Rewrite with malli #1

Closed piotr-yuxuan closed 3 years ago

piotr-yuxuan commented 3 years ago
(require '[malli.core :as malli]
         '[malli.transform :as mt])

(defrecord AvroRecord [inner-kvs])

(def avro-decoders
  {'keyword? {:enter (comp #(doto % (println :enter)) keyword)}
   'int? {:enter (comp #(doto % (println :enter)) #(Integer/parseInt %))}
   :map {:enter (comp #(doto % (println :enter)) :inner-kvs)}})

(def avro-encoders
  {'keyword? {:leave (comp #(doto % (println :leave)) name)}
   'int? {:leave (comp #(doto % (println :leave)) str)}
   :map {:leave (comp #(doto % (println :leave)) #(AvroRecord. %))}})

(def avro-transformer
  (mt/transformer
    {:name :avro
     :decoders avro-decoders
     :encoders avro-encoders}))

(let [schema [:map
              [:a keyword?
               :vector [:vector int?]]]
      value {:a :a
             :vector [1 2 3]}]
  (as-> value $
        (malli/encode schema $ avro-transformer)
        (malli/decode schema $ avro-transformer)))
piotr-yuxuan commented 3 years ago

Simplest working example, here with string. The multimethod is invoked only when the transformer is built, so the cost is not paid on encoding time. The best thing is that when entering the encoding function, malli gives us the pleasant certainty that the type is correct. This is for free.

(defmulti ->avro-fn :avro/type)

(defmethod ->avro-fn :default
  [_]
  identity)

(defmethod ->avro-fn :avro/string
  [opts]
  (case (:avro/string-type opts)
    :utf8 #(Utf8. ^String %)
    identity))

(defmethod ->avro-fn :avro/field
  [opts]
  (get opts :avro/ident-fn identity))

(defmethod ->avro-fn :avro/map
  [opts]
  (let [->avro (->avro-fn (assoc opts :avro/type :avro/field))]
    (fn [m]
      ;; Return a HashMap, so guarantee to be exactly what is expected
      ;; by Avro. Subtlety: a vector of tuples, like the result
      ;; of `(map f {})` will be cast into a HashMap.
      (let [^HashMap hm (HashMap.)]
        (doseq [[k v] m]
          (.put hm
                (->avro k)
                v))
        hm))))

(defmethod ->avro-fn :avro/array
  [_]
  (fn [s]
    ;; Return a List, so guarantee to be exactly what is expected by
    ;; Avro. Subtlety: a map, like {:a 1, :b 2} will be cast into the
    ;; List<MapEntry> [[:a 1], [:b 2]], provided the schema is valid.
    (let [^List l (ArrayList.)]
      (doseq [i s] (.add l i))
      l)))

(defn avro-encoders
  [opts]
  {'keyword? {:enter #(doto %)
              :leave #(doto %)}
   'int? {:enter #(doto %)
          :leave #(doto %)}
   'string? (->avro-fn (assoc opts :avro/type :avro/string))
   :vector {:leave (->avro-fn (assoc opts :avro/type :avro/array))}
   :map {:leave (->avro-fn (assoc opts :avro/type :avro/map))}})

(defn avro-transformer
  [opts]
  (mt/transformer
    {:name :avro
     :encoders (avro-encoders opts)}))

(def schema
  (malli/schema
    ;; Recursive, because I can.
    [:schema {:registry {::cons [:maybe [:map [:v-field [:vector [:or string? [:ref ::cons]]]]]]}}
     ::cons]))

(def value
  {:v-field [{:v-field [{:v-field [{:v-field ["lol"]}]}]}
             {:v-field []}]})

(assert (malli/validate schema value))

(let [opts {:avro/string-type :str
            :avro/ident-fn csk/->SCREAMING_SNAKE_CASE_KEYWORD}]
  (malli/encode schema value (avro-transformer opts)))
=> {:V_FIELD [{:V_FIELD [{:V_FIELD [{:V_FIELD ["lol"]}]}]} {:V_FIELD []}]}

(let [opts {:avro/string-type :utf8
            :avro/ident-fn csk/->camelCaseString}]
  (malli/encode schema value (avro-transformer opts)))
=> {"vField" [{"vField" [{"vField" [{"vField" [#object[org.apache.avro.util.Utf8 0x53367723 "lol"]]}]}]} {"vField" []}]}