diff --git a/docs/10_spec_transformations.md b/docs/10_spec_transformations.md index 5ac1561b..10d661bf 100644 --- a/docs/10_spec_transformations.md +++ b/docs/10_spec_transformations.md @@ -255,3 +255,45 @@ Or using `type-transformer` directly: st/strip-extra-keys-transformer st/strip-extra-values-transformer)) ``` + +It is also possible to add a spec to be used to validate the +transformed value. Using this feature you decouple the transformation +into two specs, the original schema before transformation and the +target schema after transformation. + +```clj +(s/def :db/hostname string?) +(s/def :db/port pos-int?) +(s/def :db/database string?) +(s/def ::jdbc-connection + (st/spec {:spec (s/keys :req-un [:db/hostname :db/port :db/database]) + :type :dbconn})) + +(defn dbconn->url + [_ {:keys [hostname port database]}] + (format "jdbc:postgres://%s:%s/%s" hostname port database)) + +(def jdbc-transformer + (st/type-transformer + {:name :jdbc + :encoders {:dbconn dbconn->url} + :default-encoder stt/any->any})) + +(st/encode + ::jdbc-connection + {:hostname "127.0.0.1" :port 5432 :database "postgres"} + jdbc-transformer) + +;; => ::s/invalid + +(s/def :db/conn-string string?) + +(st/encode + ::jdbc-connection + {:hostname "127.0.0.1" :port 5432 :database "postgres"} + jdbc-transformer + :db/conn-string) + +;; => "jdbc:postgres://127.0.0.1:5432/postgres" + +``` diff --git a/src/spec_tools/core.cljc b/src/spec_tools/core.cljc index bef8c10a..9fcb021c 100644 --- a/src/spec_tools/core.cljc +++ b/src/spec_tools/core.cljc @@ -71,8 +71,9 @@ ;; Transformers ;; -(def ^:dynamic ^:private *transformer* nil) -(def ^:dynamic ^:private *encode?* nil) +(def ^:dynamic ^:private *dynamic-conforming* nil) + +(defrecord DynamicConforming [transformer encode? spec-transformed]) (defprotocol Coercion (-coerce [this value transformer options])) @@ -180,7 +181,7 @@ ([spec value] (explain spec value nil)) ([spec value transformer] - (binding [*transformer* transformer, *encode?* false] + (binding [*dynamic-conforming* (->DynamicConforming transformer false nil)] (s/explain (into-spec spec) value)))) (defn explain-data @@ -188,7 +189,7 @@ ([spec value] (explain-data spec value nil)) ([spec value transformer] - (binding [*transformer* transformer, *encode?* false] + (binding [*dynamic-conforming* (->DynamicConforming transformer false nil)] (s/explain-data (into-spec spec) value)))) (defn conform @@ -197,7 +198,7 @@ ([spec value] (conform spec value nil)) ([spec value transformer] - (binding [*transformer* transformer, *encode?* false] + (binding [*dynamic-conforming* (->DynamicConforming transformer false nil)] (s/conform (into-spec spec) value)))) (defn conform! @@ -208,7 +209,7 @@ ([spec value] (conform! spec value nil)) ([spec value transformer] - (binding [*transformer* transformer, *encode?* false] + (binding [*dynamic-conforming* (->DynamicConforming transformer false nil)] (let [spec' (into-spec spec) conformed (s/conform spec' value)] (if-not (s/invalid? conformed) @@ -232,31 +233,42 @@ (defn decode "Decodes a value using a [[Transformer]] from external format to a value defined by the spec. First, calls [[coerce]] and returns the value if it's - valid - otherwise, calls [[conform]] & [[unform]]. Returns `::s/invalid` + valid - otherwise, calls [[conform]] & [[unform]]. You can also provide a + spec to validate the decoded value after transformation. Returns `::s/invalid` if the value can't be decoded to conform the spec." ([spec value] (decode spec value nil)) ([spec value transformer] + (decode spec value transformer nil)) + ([spec value transformer spec-transformed] (let [spec (into-spec spec) coerced (coerce spec value transformer)] (if (s/valid? spec coerced) coerced - (binding [*transformer* transformer, *encode?* false] + (binding [*dynamic-conforming* (->DynamicConforming transformer false spec-transformed)] (let [conformed (s/conform spec value)] (if (s/invalid? conformed) conformed - (s/unform spec conformed)))))))) + (if spec-transformed + (s/unform spec-transformed conformed) + (s/unform spec conformed))))))))) (defn encode "Transforms a value (using a [[Transformer]]) from external - format into a value defined by the spec. On error, returns `::s/invalid`." - [spec value transformer] - (binding [*transformer* transformer, *encode?* true] - (let [spec (into-spec spec) - conformed (s/conform spec value)] - (if (s/invalid? conformed) - conformed - (s/unform spec conformed))))) + format into a value defined by the spec. You can also provide a + spec to validate the encoded value after transformation. + On error, returns `::s/invalid`." + ([spec value transformer] + (encode spec value transformer nil)) + ([spec value transformer spec-transformed] + (binding [*dynamic-conforming* (->DynamicConforming transformer true spec-transformed)] + (let [spec (into-spec spec) + conformed (s/conform spec value)] + (if (s/invalid? conformed) + conformed + (if spec-transformed + (s/unform spec-transformed conformed) + (s/unform spec conformed))))))) (defn select-spec "Best effort to drop recursively all extra keys out of a keys spec value." @@ -400,7 +412,7 @@ s/Spec (conform* [this x] - (let [transformer *transformer*, encode? *encode?*] + (let [{:keys [transformer encode? spec-transformed]} *dynamic-conforming*] ;; if there is a transformer present (if-let [transform (if transformer ((if encode? -encoder -decoder) transformer (decompose-spec-type this) x))] ;; let's transform it @@ -408,11 +420,15 @@ ;; short-circuit on ::s/invalid (or (and (s/invalid? transformed) transformed) ;; recur - (let [conformed (s/conform spec transformed)] + (let [conformed (if spec-transformed + (binding [*dynamic-conforming* (->DynamicConforming nil encode? nil)] + (s/conform spec-transformed transformed)) + (s/conform spec transformed))] ;; it's ok if encode transforms leaf values into invalid values - (or (and encode? (s/invalid? conformed) (leaf? this) transformed) conformed)))) + (or (and spec-transformed conformed) + (and encode? (s/invalid? conformed) (leaf? this) transformed) + conformed)))) (s/conform spec x)))) - (unform* [_ x] (s/unform spec x)) diff --git a/test/cljc/spec_tools/core_test.cljc b/test/cljc/spec_tools/core_test.cljc index b1bf46df..b5aef126 100644 --- a/test/cljc/spec_tools/core_test.cljc +++ b/test/cljc/spec_tools/core_test.cljc @@ -858,6 +858,35 @@ :ref (s/map-of qualified-keyword? (s/or :rec-pattern ::rec-pattern))))) +(s/def :db/hostname string?) +(s/def :db/port pos-int?) +(s/def :db/database string?) +(s/def ::jdbc-connection + (st/spec {:spec (s/keys :req-un [:db/hostname :db/port :db/database]) + :type :dbconn})) + +(defn dbconn->url + [_ {:keys [hostname port database]}] + #?(:clj (clojure.core/format "jdbc:postgres://%s:%s/%s" hostname port database) + :cljs (cljs.pprint/cl-format nil "jdbc:postgres://~a:~a/~a" hostname port database))) + +(def jdbc-transformer + (st/type-transformer + {:name :jdbc + :encoders {:dbconn dbconn->url} + :default-encoder stt/any->any})) + +(s/def :db/connection-string (st/spec {:spec string? + :type :dbconn})) + +(deftest issue-241 + (testing "provide a spec to validate transformed values" + (let [valid-input {:hostname "127.0.0.1" :port 5432 :database "postgres"}] + (is (thrown? #?(:clj ClassCastException :cljs js/Error) + (st/encode ::jdbc-connection valid-input jdbc-transformer))) + (is (= (st/encode ::jdbc-connection valid-input jdbc-transformer :db/connection-string) + "jdbc:postgres://127.0.0.1:5432/postgres"))))) + (deftest issue-244 (testing "stop rewalking recursive specs." (let [correct-data [:core-test/stack