Rovanion
Rovanion

Reputation: 4582

Plumatic Schema for keyword arguments

Say we have a function get-ints with one positional argument, the number of ints the caller wants, and two named arguments :max and :min like:

; Ignore that the implementation of the function is incorrect.
(defn get-ints [nr & {:keys [max min] :or {max 10 min 0}}]
  (take nr (repeatedly #(int (+ (* (rand) (- max min -1)) min)))))

(get-ints 5)               ; => (8 4 10 5 5)
(get-ints 5 :max 100)      ; => (78 43 32 66 6)
(get-ints 5 :min 5)        ; => (10 5 9 9 9)
(get-ints 5 :min 5 :max 6) ; => (5 5 6 6 5)

How does one write a Plumatic Schema for the argument list of get-ints, a list of one, three or five items where the first one is always a number and the following items are always pairs of a keyword and an associated value.

With Clojure Spec I'd express this as:

(require '[clojure.spec :as spec])
(spec/cat :nr pos-int? :args (spec/keys* :opt-un [::min ::max]))

Along with the separate definitions of valid values held by ::min and ::max.

Upvotes: 1

Views: 433

Answers (2)

Rovanion
Rovanion

Reputation: 4582

Based on the answer I got from the Plumatic mailing list [0] [1] I sat down and wrote my own conformer outside of the schema language itself:

(defn key-val-seq?
  ([kv-seq]
   (and (even? (count kv-seq))
        (every? keyword? (take-nth 2 kv-seq))))
  ([kv-seq validation-map]
   (and (key-val-seq? kv-seq)
        (every? nil? (for [[k v] (partition 2 kv-seq)]
                       (if-let [schema (get validation-map k)]
                         (schema/check schema v)
                         :schema/invalid))))))

(def get-int-args
  (schema/constrained
   [schema/Any]
   #(and (integer? (first %))
         (key-val-seq? (rest %) {:max schema/Int :min schema/Int}))))

(schema/validate get-int-args '())               ; Exception: Value does not match schema...
(schema/validate get-int-args '(5))              ; => (5)
(schema/validate get-int-args [5 :max 10])       ; => [5 :max 10]
(schema/validate get-int-args [5 :max 10 :min 1]); => [5 :max 10 :min 1]
(schema/validate get-int-args [5 :max 10 :b 1])  ; Exception: Value does not match schema...

Upvotes: 1

Alan Thompson
Alan Thompson

Reputation: 29984

I think this is a case when it is easier to write the specific code you need rather than trying to force-fit a solution using Plumatic Schema or some other tool that is not designed for this use-case. Keep in mind that Plumatic Schema & other tools (like the built-in Clojure pre- & post-conditions) are just a shorthand way of throwing an Exception when some condition is violated. If none of these DSL's are suitable, you always have the general-purpose language to fall back on.

A similar situation to yours can be found in the Tupelo library for the rel= function. It is designed to perform a test for "relative equality" between two numbers. It works like so:

(is      (rel=   123450000   123456789 :digits 4 ))       ; .12345 * 10^9
(is (not (rel=   123450000   123456789 :digits 6 )))
(is      (rel= 0.123450000 0.123456789 :digits 4 ))       ; .12345 * 1
(is (not (rel= 0.123450000 0.123456789 :digits 6 )))

(is      (rel= 1 1.001 :tol 0.01 ))                       ; :tol value is absolute error
(is (not (rel= 1 1.001 :tol 0.0001 )))

While nearly all other functions in the Tupelo library make heavy use of Plumatic Schema, this one does it "manually":

(defn rel=
  "Returns true if 2 double-precision numbers are relatively equal, else false.  Relative equality
   is specified as either (1) the N most significant digits are equal, or (2) the absolute
   difference is less than a tolerance value.  Input values are coerced to double before comparison.
   Example:

     (rel= 123450000 123456789   :digits 4   )  ; true
     (rel= 1         1.001       :tol    0.01)  ; true
   "
  [val1 val2 & {:as opts}]
  {:pre  [(number? val1) (number? val2)]
   :post [(contains? #{true false} %)]}
  (let [{:keys [digits tol]} opts]
    (when-not (or digits tol)
      (throw (IllegalArgumentException.
               (str "Must specify either :digits or :tol" \newline
                 "opts: " opts))))
    (when tol
      (when-not (number? tol)
        (throw (IllegalArgumentException.
                 (str ":tol must be a number" \newline
                   "opts: " opts))))
      (when-not (pos? tol)
        (throw (IllegalArgumentException.
                 (str ":tol must be positive" \newline
                   "opts: " opts)))))
    (when digits
      (when-not (integer? digits)
        (throw (IllegalArgumentException.
                 (str ":digits must be an integer" \newline
                   "opts: " opts))))
      (when-not (pos? digits)
        (throw (IllegalArgumentException.
                 (str ":digits must positive" \newline
                   "opts: " opts)))))
    ; At this point, there were no invalid args and at least one of 
    ; either :tol and/or :digits was specified.  So, return the answer.
    (let [val1      (double val1)
          val2      (double val2)
          delta-abs (Math/abs (- val1 val2))
          or-result (truthy?
                      (or (zero? delta-abs)
                        (and tol
                          (let [tol-result (< delta-abs tol)]
                            tol-result))
                        (and digits
                          (let [abs1          (Math/abs val1)
                                abs2          (Math/abs val2)
                                max-abs       (Math/max abs1 abs2)
                                delta-rel-abs (/ delta-abs max-abs)
                                rel-tol       (Math/pow 10 (- digits))
                                dig-result    (< delta-rel-abs rel-tol)]
                            dig-result))))
          ]
      or-result)))

Upvotes: 1

Related Questions