1
votes

I'm learning Clojure and enjoying it but find an inconsistency in Records that puzzles me: why doesn't the default map constructor (map->Whatever) check for data integrity when creating a new Record? For instance:

user=> (defrecord Person [first-name last-name])
#<Class@46ffda99 user.Person>
user=> (map->Person {:first-name "Rich" :last-name "Hickey"})
#user.Person {:first-name "Rich" :last-name "Hickey"}
user=> (map->Person {:first-game "Rich" :last-name "Hickey"})
#user.Person {:first-game "Rich" :first-name nil :last-name "Hickey"}

I believe the Map is not required to define all the fields in the Record definition and it is also allowed to contain extra fields that aren't part of the Record definition. Also I understand that I can define my own constructor which wraps the default constructor and I think a :post condition can then be used to check for correct (and comprehensive) Record creation (have not been successful in getting that to work).

My question is: Is there an idiomatic Clojure way to verify data during Record construction from a Map? And, is there something that I'm missing here about Records?

Thank you.

2
I honestly rarely have the need to convert a map to a record. I just use the raw constructor. What's the use case? If you're serializing you can use EDN to make it safe.Carcigenicate
I was just refactoring an old code problem which used maps to now use records as a way of understanding their properties. JSON -> vector of maps would become JSON -> vector of records. Thx for EDN reference. Will investigate.ericky

2 Answers

5
votes

I think your comprehensiveness requirement is already quite specific, so nothing built-in I know of covers this.

One thing you can do nowadays is use clojure.spec to provide an s/fdef for your constructor function (and then instrument it).

(require '[clojure.spec.alpha :as s]
         '[clojure.spec.test.alpha :as stest])

(defrecord Person [first-name last-name])

(s/fdef map->Person
  :args (s/cat :map (s/keys :req-un [::first-name ::last-name])))

(stest/instrument `map->Person)

(map->Person {:first-name "Rich", :last-name "Hickey"})
(map->Person {:first-game "Rich", :last-name "Hickey"})  ; now fails

(If specs are defined for ::first-name and ::last-name those will be checked as well.)

1
votes

Another option is to use Plumatic Schema to create a wrapper "constructor" function specifying the allowed keys. For example:

(def FooBar {(s/required-key :foo) s/Str (s/required-key :bar) s/Keyword})

(s/validate FooBar {:foo "f" :bar :b})
;; {:foo "f" :bar :b}

(s/validate FooBar {:foo :f})
;; RuntimeException: Value does not match schema:
;;  {:foo (not (instance? java.lang.String :f)),
;;   :bar missing-required-key}

The first line defines a schema that accepts only maps like:

{ :foo "hello"  :bar :some-kw }

You wrapper constructor would look something like:

(def NameMap {(s/required-key :first-name) s/Str (s/required-key :last-name) s/Str})

(s/defn safe->person 
  [name-map :- NameMap]
  (map->Person name-map))

or

(s/defn safe->person-2
  [name-map]
  (assert (= #{:first-name :last-name} (set (keys name-map))))
  (map->Person name-map))