Skip to content

Commit

Permalink
Add :closed-keys? options (closed maps)
Browse files Browse the repository at this point in the history
This allows to have coax strip out unknown keys from maps that are specced with
s/keys.
  • Loading branch information
mpenet committed Dec 25, 2023
1 parent e927658 commit 321f198
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 37 deletions.
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ Most Coax "public" functions take a spec and a value and try to return a new
value that conforms to that spec by altering the input value when possible and
most importantly when it makes sense.

Coax is centred around its own registry for coercion rules, when a coercion is
Coax is centered around its own registry for coercion rules, when a coercion is
not registered it can infer in most cases what to do to coerce a value into
something that conforms to a spec. It also supports "coerce time" options to
enable custom coercion from any spec type, including spec `forms` (like
s/coll-of & co) or just `idents` (predicates, registered specs).

Coax initially started as a fork of spec-coerce, but nowadays the internals and
the API are very different. Wilker Lúcio's approach gave us a nice outline of
the API are totally different. Wilker Lúcio's approach gave us a nice outline of
how such library could expose its functionality.

## What

The typical (infered) example would be :
The typical (inferred) example would be :

```clj
(s/def ::foo keyword?)
Expand Down Expand Up @@ -71,10 +71,39 @@ you to support any spec form like inst-in, coll-of, .... You could
easily for instance generate open-api definitions using these.

```clj
(s/coerce ::foo (s/coll-of keyword?)
(c/coerce ::foo (s/coll-of keyword?)
{::c/forms {`s/coll-of (fn [[_ spec]] (fn [x opts] (do-something-crazy-with-spec+the-value spec x opts)))}})
```

## Closed keys

`coax` also allows to *close* maps specced with `s/keys`.

If you call `coerce` using the option `{:closed-keys? true ...}` if a value
corresponding to a `s/keys` spec is encountered it will effectively remove all
unknown keys from the returned value.

``` clj
(s/def ::foo string?)
(s/def ::bar string?)
(s/def ::z string?)

(s/def ::m (s/keys :req-un [::foo ::bar]))

(c/coerce ::m {:foo "f" :bar "b" :baz "z"} {:closed-keys? true}) ; baz is not on the spec
-> {:foo "f" :bar "b"} ; it gets removed

;; this works in any s/keys matching spec in the data passed:
(coerce `(s/coll-of ::m) [{:foo "f" :bar "b" :baz "x"}] {:closed-keys? true})
-> [{:foo "f" :bar "b"}]

;; also plays nice with s/merge, multi-spec & co
(coerce `(s/merge ::m (s/keys :req-un [::z]))
{:foo "f" :bar "b" :baz "x" :z "z"} {:closed-keys? true})

-> {:foo "f" :bar "b" :z "z"}
```

## Documentation

[![cljdocbadge](https://cljdoc.xyz/badge/exoscale/coax)](https://cljdoc.org/d/exoscale/coax/CURRENT/api/exoscale.coax)
Expand Down
73 changes: 43 additions & 30 deletions src/exoscale/coax.cljc
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
(ns exoscale.coax
(:refer-clojure :exclude [def])
(:require [exoscale.coax.inspect :as si]
[exoscale.coax.coercer :as c]
(:require #?(:clj [net.cgrand.macrovich :as macros])
[clojure.spec.alpha :as s]
[clojure.walk :as walk]
#?(:clj [net.cgrand.macrovich :as macros]))
[exoscale.coax.coercer :as c]
[exoscale.coax.inspect :as si])
#?(:clj
(:import (clojure.lang Keyword)
(java.util Date UUID)
Expand Down Expand Up @@ -40,24 +40,32 @@
(coerce spec x opts)))

(defn gen-coerce-keys
[[_ & {:keys [req-un opt-un]}]]
(let [keys-mapping (into {}
(comp (filter keyword?)
(map #(vector (keyword (name %)) %)))
(flatten (concat req-un opt-un)))]
(fn [x opts]
[[_ & {:as _opts :keys [req-un opt-un req opt]}]]
(let [keys-mapping-unns (into {}
(keep #(when (keyword? %)
[(keyword (name %)) %]))
(flatten (concat req-un opt-un)))
keys-mapping-ns (into {}
(map (juxt identity identity))
(flatten (concat req opt)))
keys-mapping (merge keys-mapping-unns keys-mapping-ns)]
(fn [x {:as opts :keys [closed-keys?]}]
(if (map? x)
(reduce-kv (fn [m k v]
(assoc m
k
(let [s (or (keys-mapping k) k)]
;; only try to coerce registered specs
;; from mapping
(if (qualified-ident? s)
(let [s-from-mapping (keys-mapping k)
s (or s-from-mapping k)]
(cond
;; if closed and not in mapping dissoc
(and closed-keys? (not s-from-mapping))
(dissoc m k)
;; registered spec -> coerce
(qualified-ident? s)
(assoc m k
(coerce s
v
opts)
v))))
opts))
;; passthrough
:else m)))
x
x)
:exoscale.coax/invalid))))
Expand Down Expand Up @@ -119,20 +127,14 @@

(defn gen-coerce-merge
[[_ & spec-forms]]
(fn [x opts]
(fn [x {:as opts :keys [closed-keys?]}]
(if (map? x)
(reduce (fn [m spec-form]
;; for every spec-form coerce to new value;
;; we need to compare key by key what changed so that
;; defaults do not overwrite coerced values
(into m
(keep (fn [[spec v]]
;; new-val doesn't match default, keep it
(when-not (= (get x spec) v)
[spec v])))
(coerce spec-form x opts)))
x
spec-forms)
(into (if closed-keys? {} x)
(map (fn [spec-form]
(coerce spec-form
x
(assoc opts :closed-keys? true))))
spec-forms)
:exoscale.coax/invalid)))

(defn gen-coerce-nilable
Expand Down Expand Up @@ -425,3 +427,14 @@
[k (op (get idents k k) v opts)]
[k v]))))))
x)))

(s/def ::foo string?)
(s/def ::bar string?)
(s/def ::z string?)

(s/def ::m (s/keys :req-un [::foo ::bar]))

(coerce ::m {:foo "f" :bar "b" :baz "x"} {:closed-keys? true}) ; baz is not on the spec

(coerce `(s/merge ::m (s/keys :req-un [::z]))
{:foo "f" :bar "b" :baz "x" :z "z"} {:closed-keys? true}) ; baz is not on the spec
25 changes: 22 additions & 3 deletions test/exoscale/coax_test.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
[exoscale.coax :as sc]))
(:require
#?(:clj [clojure.test :refer [deftest testing is are]])
#?(:clj [clojure.test.check.clojure-test :refer [defspec]])
#?(:cljs [clojure.test.check.clojure-test :refer-macros [defspec]])
[clojure.spec.alpha :as s]
[clojure.spec.test.alpha :as st]
[clojure.string :as str]
[clojure.test.check :as tc]
[clojure.test.check.generators]
[clojure.test.check.properties :as prop]
[clojure.spec.test.alpha :as st]
#?(:clj [clojure.test.check.clojure-test :refer [defspec]])
#?(:cljs [clojure.test.check.clojure-test :refer-macros [defspec]])
[exoscale.coax :as sc]
[exoscale.coax.coercer :as c])
#?(:clj (:import (java.net URI))))
Expand Down Expand Up @@ -375,6 +375,20 @@
(is (= (sc/coerce ::unqualified {:foo "1" :bar "hi"})
{:foo 1 :bar "hi"})))

(deftest test-closed-keys
(s/def ::zzz string?)
(s/def ::test-closed-keys (s/keys :req [::bar ::foo]))
(is (= (sc/coerce ::test-closed-keys {::foo 1 ::bar 2 ::zzz 3})
{::foo 1 ::bar "2" ::zzz "3"}))
(is (= (sc/coerce ::test-closed-keys {::foo 1 ::bar 2 ::baz 3} {:closed-keys? true})
{::foo 1 ::bar "2"}))

(s/def ::test-closed-keys2 (s/keys :req-un [::bar ::foo]))
(is (= (sc/coerce ::test-closed-keys2 {:foo 1 :bar 2 :zzz 3})
{:foo 1 :bar "2" :zzz 3}))
(is (= (sc/coerce ::test-closed-keys2 {:foo 1 :bar 2 :baz 3} {:closed-keys? true})
{:foo 1 :bar "2"})))

(s/def ::tuple (s/tuple ::foo ::bar int?))

(deftest test-tuple
Expand All @@ -389,10 +403,15 @@
(is (= {:foo 1 :bar "1" :c {:a 2}}
(sc/coerce ::merge {:foo "1" :bar 1 :c {:a 2}}))
"Coerce new vals appropriately")

(is (= {:foo 1 :bar "1" :c {:a 2}}
(sc/coerce ::merge {:foo 1 :bar "1" :c {:a 2}}))
"Leave out ok vals")

(is (= {:foo 1 :bar "1" :c {:a 2}}
(sc/coerce ::merge {:foo "1" :bar 1 :c {:a 2}}))
"Coerce new vals appropriately")

(s/def ::merge2 (s/merge (s/keys :req [::foo])
::unqualified))

Expand Down

0 comments on commit 321f198

Please sign in to comment.