metosin / malli

High-performance data-driven data specification library for Clojure/Script.
Eclipse Public License 2.0
1.51k stars 212 forks source link

Consider adding a clojure.spec multi-spec equivalent #786

Open jeroenvandijk opened 2 years ago

jeroenvandijk commented 2 years ago

In clojure.spec multi-spec allows to dynamically extend specs. This gives a lot of flexibility. For example, it allows to create a plugin system where definitions are loaded at a later time, in a different part of the code. Quoting spec's documentation:

The multi-spec approach allows us to create an open system for spec validation

E.g. consider the small repl session below, it shows that a spec can be extended and redefined at runtime.

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

(defmulti event-type :event/type)
(s/def :event/event (s/multi-spec event-type :event/type))

(s/valid? :event/event
          {:event/type :event/search
           :search/url "https://clojure.org"}) ;=> false

(defmethod event-type :event/search [_]
  (s/keys :req [:event/type :search/url]))

(s/valid? :event/event
  {:event/type :event/search
   :search/url "https://clojure.org"}) ;=> true

;; Setting event-type to nil shows that the multispec is connected to the var and not the multimethod itself
(def event-type nil)
(s/valid? :event/event
          {:event/type :event/search
           :search/url "https://clojure.org"}) ;=> Exception

(defmulti event-type :event/type)
(s/valid? :event/event
          {:event/type :event/search
           :search/url "https://clojure.org"}) ;=> false
;; etc ..

In Malli there is the :multi schema. Currently this supports loading predefined keys lazily. It does not support a clean way to redefine, let alone add or remove keys at a later stage. In a Slack discussion with @ikitommi it was suggested to add a :methods option to dynamically define the keys. I tried this in a seperate PR, but this only adds keys lazily and doesn't allow to redefine the keys.

If I have to think of an implementation similar to multispec it has to be with a dynamic entry parser (not just lazy) and maybe add-watch on the multimethod var. The clojure.spec implementation looks different though. And after some experimentation I noticed that adding a defmethod doesn't trigger a var change, so add-watch wouldn't work here.

[1] https://clojurians.slack.com/archives/CLDK6MFMK/p1669045351866979

ikitommi commented 1 year ago

I think a generic solution to have a Schema wrapper that allows one to create the current snapshot of the Schema instance programmatically. This would work with all Schema types, not just with :multi.

Did a prototype, works like this:

(def schema* (atom :int))

(def mut (mu/mutable #(m/schema @schema*)))

(m/form [:map [:mut mut]])
; => [:map [:mut :int]]

(reset! schema* [:enum "so" "mutable"])

(m/form [:map [:mut mut]])
; => [:map [:mut [:enum "so" "mutable"]]]

with multimethods:

(defmulti my-schema :type)

(defmethod my-schema :user [_]
  [:map
   [:type [:= :user]]
   [:name :string]])

(def multi
  (mu/mutable
   #(m/into-schema
     :multi
     {:dispatch :type}
     (map (fn [[type f]] [type (f {:type type})]) (methods my-schema)))))

multi
;[:multi {:dispatch :type}
; [:user [:map
;         [:type [:= :user]]
;         [:name :string]]]]

(m/validate multi {:type :user, :name "kikka"})
; => true

(m/validate multi {:type :fruit, :taste "sweet"})
; => false

(defmethod my-schema :fruit [_]
  [:map
   [:type [:= :fruit]]
   [:taste [:enum "sweet" "sour"]]])

multi
;[:multi
; {:dispatch :type}
; [:fruit [:map
;          [:type [:= :fruit]]
;          [:taste [:enum "sweet" "sour"]]]]
; [:user [:map
;         [:type [:= :user]]
;         [:name :string]]]]

(m/validate multi {:type :user, :name "kikka"})
; => true

(m/validate multi {:type :fruit, :taste "sweet"})
; => true

the Schema instance is mutable itself, with no caching enabled, so calling (m/validate multi ...) always uses the latest value. Calling (m/validator multi) will create a immutable snapshot with the current value.

would think work with you?

ikitommi commented 1 year ago

... with suger:

(defn multimethod-schema [mm]
  (let [dispatch (.dispatchFn mm)]
    (m/into-schema
     :multi
     {:dispatch dispatch}
     (map (fn [[type f]] [type (f {dispatch type})]) (methods mm)))))

in use:

(mu/multimethod-schema my-schema)
;[:multi
; {:dispatch :type}
; [:fruit [:map
;          [:type [:= :fruit]]
;          [:taste [:enum "sweet" "sour"]]]]
; [:user [:map
;         [:type [:= :user]]
;         [:name :string]]]]

;; the previous example
(def multi (mu/mutable #(mu/multimethod-schema my-schema)))
jeroenvandijk commented 1 year ago

@ikitommi Yeah I think this would work. Relying on mu/mutable also makes it explicit that the implementation relies on state. In development I could use mu/mutable and in production, for better performance, I could decide to use the initial schema as the final schema (assuming I'm sure all multimethod extensions are loaded).