Search code examples
clojuregraphqlclojure.spec

Spec for map with interdependent values between nested levels?


I'm trying to define a spec for a portion of the GraphQL schema syntax. Here's what a field type looks like as returned from an API (note that :ofType can be infinitely nested):

{:kind "NON_NULL",
 :name nil,
 :ofType {:kind "LIST",
          :name nil,
          :ofType {:kind "NON_NULL",
                   :name nil,
                   :ofType {:kind "OBJECT", :name "Comment"}}}}

Currently I have a spec like so to represent this structure:

(spec/def ::kind #{"NON_NULL" "LIST" "SCALAR" "OBJECT"})
(spec/def ::name (spec/nilable string?))
(spec/def ::ofType (spec/or :terminal nil?
                            :type ::type))

(spec/def ::type
  (spec/keys :req-un [::name ::kind ::ofType]))

This is an ok solution, but there are some invariants that I haven't figured out how to capture:

  1. :name must be nil at all levels except the deepest (terminal) level.
  2. :kind can only equal SCALAR or OBJECT at the terminal level.
  3. :kind must equal either SCALAR or OBJECT at the terminal level.
  4. :kind cannot equal NON_NULL in two consecutive levels.

Is it possible to capture these rules inside of a spec? Or, if not, is it possible to write a custom generator which abides by these rules?

UPDATE - Generator solution

I was able to build a generator for this purpose. See Rulle's answer below for how to spec this directly.


(spec/def ::kind #{"NON_NULL" "LIST" "SCALAR" "OBJECT"})
(spec/def ::name (spec/nilable string?))
(spec/def ::ofType (spec/or :terminal nil?
                            :type ::type))

(spec/def ::type
  (spec/keys :req-un [::name ::kind ::ofType]))

(spec/def ::terminal-kind #{"SCALAR" "OBJECT"})
(spec/def ::terminal-name string?)

(def terminal-gen
  "Returns a generator for a terminal field type.
  Terminal field types are either of :kind 'OBJECT' or 'SCALAR', have an :ofType of nil, and a non-nil :name."
  (gen/bind
    (spec/gen (spec/tuple ::terminal-name ::terminal-kind))
    (fn [[name kind]]
      (gen/hash-map
        :name (spec/gen #{name})
        :kind (spec/gen #{kind})
        :ofType (gen/return nil)))))

(defn build-type-gen
  "Returns a generator which constructs a field type data structure.

  An example of a field type:

   { :name nil,
     :kind 'NON_NULL',
     :ofType {:name nil, <-- a 'modifier layer', these can be infinitely nested
              :kind 'LIST',
              :ofType {:name nil,
                       :kind 'LIST',
                       :ofType {:name 'M17Pyn0zClVD', :kind 'OBJECT', :ofType nil}}}}} <-- Terminal field type

  This function works by creating a terminal type generator, then 'wrapping' it with layer generators (NON NULL and LIST)
  until it's a given depth. The following constraints are ensured through the process:

  1. :name must be nil at all levels except the deepest (terminal) level.
  2. :kind can only equal SCALAR or OBJECT at the terminal level.
  3. :kind must equal either SCALAR or OBJECT at the terminal level.
  4. :kind cannot equal NON_NULL in two consecutive levels."
  ([max-depth] (if (= max-depth 1) terminal-gen
                                   (build-type-gen max-depth 0 terminal-gen)))
  ([max-depth curr-depth inner-gen]
   (if (< curr-depth max-depth)
     (recur max-depth
            (inc curr-depth)
            (gen/bind inner-gen
                      (fn [inner-gen]
                        (if (= "NON_NULL" (:kind inner-gen))
                          (gen/hash-map
                            :name (gen/return nil)
                            :kind (spec/gen #{"LIST"}) ; two NON_NULLs cannot be child-parent
                            :ofType (spec/gen #{inner-gen}))
                          (gen/hash-map
                            :name (gen/return nil)
                            :kind (spec/gen #{"NON_NULL" "LIST"})
                            :ofType (spec/gen #{inner-gen}))))))
     inner-gen)))

(def type-gen (gen/bind (spec/gen (spec/int-in 1 5)) build-type-gen))

Example:

(gen/generate type-gen)
=>
{:name nil,
 :kind "LIST",
 :ofType {:name nil,
          :kind "LIST",
          :ofType {:name nil,
                   :kind "NON_NULL",
                   :ofType {:name nil, :kind "LIST", :ofType {:name "KmgbOsy", :kind "SCALAR", :ofType nil}}}}}

Solution

  • You could probably separate it into inner and outer varieties of the ofType, like this:

    (spec/def :inner/name nil?)
    (spec/def :inner/kind #{"NON_NULL" "LIST" "SCALAR" "OBJECT"})
    (spec/def :inner/ofType (spec/or :inner ::inner
                                     :outer ::outer))
    (spec/def ::inner (spec/keys :req-un [:inner/name :inner/kind :inner/ofType]))
    
    (spec/def :outer/name (spec/nilable string?))
    (spec/def :outer/kind #{"SCALAR" "OBJECT"})
    (spec/def :outer/ofType nil?)
    (spec/def ::outer (spec/keys :req-un [:outer/name :outer/kind] :opt-un [:outer/ofType]))
    
    (spec/def ::ofType (spec/or :terminal nil?
                                :inner ::inner
                                :outer ::outer))
    

    The last condition, that two NON_NULL must not follow each other, could be dealt with separately. Note that this function will receive the conformed value when used later on in the spec:

    (defn kinds-ok? [of-type]
      (->> (second of-type)
           (iterate (comp second :ofType))
           (map :kind)
           (take-while some?)
           (partition 2 1)
           (some #{["NON_NULL" "NON_NULL"]})
           not))
    

    Then we create a top-level spec with this extra condition:

    (spec/def ::top (spec/and ::ofType kinds-ok?))
    

    The fact that kinds-ok? will receive the conformed value and not the raw value is a somewhat surprising, or even annoying, aspect of spec. But this is how it is designed. I am not sure how to make it receive the raw value: if someone knows, feel free to suggest.