Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add feature to provide an additional spec to encode/decode functions. #248

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/10_spec_transformations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

```
58 changes: 37 additions & 21 deletions src/spec_tools/core.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down Expand Up @@ -180,15 +181,15 @@
([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
"Like `clojure.core.alpha/explain-data` but supports transformers"
([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
Expand All @@ -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!
Expand All @@ -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)
Expand All @@ -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."
Expand Down Expand Up @@ -400,19 +412,23 @@

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
(let [transformed (transform this x)]
;; 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))

Expand Down
29 changes: 29 additions & 0 deletions test/cljc/spec_tools/core_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down