diff --git a/CHANGELOG.md b/CHANGELOG.md index e76c11a..6cd1b96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ### Added +- `:multi` schema support: branches are selected with a *map*-selection — + keys are dispatch values, values are sub-selections. `{:human [:name]}` + requires `:name` in branch `:human`; unmentioned branches become + all-optional. `'*` as branch key addresses every branch (its sub-selection + must be satisfiable in each; `'*` merges with explicit branch keys). A + keyword `:dispatch` key is auto-required in every explicit map-branch. + Pruning (`^:only`) drops unmentioned branches (mentioning none keeps all). + `selectable-paths` and verification errors spell branch segments as + `{:branch dispatch-value}`, and shape mismatches (vector selection on a + `:multi`, map selection on a `:map`) come with a `:hint`. + See the README's "Multi-schemas" section, incl. limitations. + - ClojureScript support: the library is now `.cljc` and tested on Node (`clojure -M:cljs-test`) as well as the JVM. - `:verify-selection` accepts two new values besides `:throw`/`:assert` and @@ -28,5 +40,18 @@ If you were catching `AssertionError` or matching the assert message, update your code. The preferred spelling of the `:verify-selection` option is now `:throw` (the default); `:assert` still works, as do the `:skip`/`nil`/`false` opt-outs. +- Selections with duplicate keys now merge instead of "last wins" + ([`404d677`](https://github.com/eval/malli-select/commit/404d677)). + `[{:friends [:name]} {:friends [:age]}]` now requires both `:name` and + `:age` of friends; it used to require only `:age`. The merge is a union of + required paths, so results only get stricter, and it applies at every + nesting level. + Consequences: + - To override instead of merge, build a single map yourself. + - A later `{:friends []}` is now a no-op; it used to reset the + `:friends` sub-selection to all-optional. + - With `:prune-optionals` / `^:only`, merged selections keep more + attributes, so generated samples can gain fields. -[Unreleased]: https://github.com/eval/malli-select/compare/v0.7.0...HEAD +Older releases are documented on the +[GitHub releases page](https://github.com/eval/malli-select/releases). diff --git a/README.md b/README.md index 0fc34b7..bbb725d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,62 @@ user=> (alter-var-root #'ms/*verify-selection* (constantly :log)) ;; CLJS: (set! See [the tests](./test/malli_select/core_test.cljc) for more. +## Multi-schemas + +Branches of a `:multi` schema are selected with a *map*: keys are dispatch +values, values are sub-selections. The selection mirrors the shape of the +schema — a vector addresses attributes of a `:map`, a map addresses branches +of a `:multi` — at any nesting depth. + +``` clojure +user=> (def Animal + [:multi {:dispatch :type} + [:human [:map [:type :keyword] [:name string?] [:age pos-int?]]] + [:sized [:map [:type :keyword] [:size pos-int?]]]]) + +;; require :name in the :human branch; other branches become all-optional +user=> (p (ms/select Animal {:human [:name]})) +[:multi {:dispatch :type} + [:human [:map [:type :keyword] [:name string?] [:age {:optional true} pos-int?]]] + [:sized [:map [:type :keyword] [:size {:optional true} pos-int?]]]] + +;; note that :type stays required: a keyword :dispatch key is auto-required in +;; every explicit map-branch, as data without it won't dispatch anyway. +;; (`::m/default` and nil branches are left alone - they also match data +;; *without* a dispatch value.) + +;; '* addresses all branches... +user=> (p (ms/select Animal {'* [:type]})) + +;; ...so the sub-selection must be satisfiable in *every* branch: +user=> (ms/select Animal {'* [:name]}) +Execution error (ExceptionInfo) ... +;; :available contains what all branches have in common, e.g. [{:branch *} :type] + +;; be more precise ('* merges with explicit branches): +user=> (ms/select Animal {'* [:type] :human [:name]}) +;; ...or skip verification (branches lacking :name are then left as-is): +user=> (ms/select Animal {'* [:name]} {:verify-selection false}) + +;; a nested multi (a vector selection addresses map-attributes, +;; a map selection addresses branches): +user=> (def Person [:map [:id :int] [:pet [:multi {:dispatch :kind} + [:dog [:map [:kind :keyword] [:breed string?]]] + [:cat [:map [:kind :keyword] [:lives :int]]]]]]) +user=> (p (ms/select Person [:id {:pet {:dog [:breed]}}])) + +;; pruning drops unmentioned branches (mentioning none keeps all), +;; e.g. to generate only humans: +user=> (mg/generate (ms/select Animal ^:only {:human [:name]})) +{:name "x2Ep", :type :human} +``` + +Limitations: +- Branches that are not `:map` schemas (e.g. `[:multi ... [:str :string]]`) pass through untouched and cannot be selected. +- Numeric dispatch values `0` and `1` are unsupported (they collide with how paths are cleaned internally). +- A function-valued `:dispatch` disables the dispatch-key auto-require. + + ## LICENSE Copyright (c) 2026 Gert Goet, ThinkCreate. diff --git a/src/malli_select/core.cljc b/src/malli_select/core.cljc index 2049b03..6f54510 100644 --- a/src/malli_select/core.cljc +++ b/src/malli_select/core.cljc @@ -1,6 +1,7 @@ (ns malli-select.core "Select a subset of a malli schema." - (:require [malli.core :as m] + (:require [clojure.set :as set] + [malli.core :as m] [malli.util :as mu])) (def ^:dynamic *verify-selection* @@ -29,22 +30,71 @@ r (conj! r phead))))))) - -(defn- map-schema-path-walker [f] +(defn- branch-seg? + "Is `x` a path segment addressing a branch of a `:multi`?" + [x] + (and (map? x) (contains? x :branch))) + +(defn- untag-path + "Strip `{:branch k}` tags from `path`." + [path] + (mapv #(if (branch-seg? %) (:branch %) %) path)) + +(defn- tag-path + "Tag every segment of cleaned `path` that crosses a `:multi` (per + `multi-paths`) as `{:branch seg}`." + [multi-paths path] + (if (empty? multi-paths) + path + (loop [prefix [] tagged [] [seg & more :as segs] (seq path)] + (if-not segs + tagged + (recur (conj prefix seg) + (conj tagged (if (contains? multi-paths prefix) {:branch seg} seg)) + more))))) + +(defn- multi-branch-keys + "Map of cleaned path → set of branch keys, for every `:multi` in `schema`." + [schema] + (let [!acc (atom {})] + (m/walk schema + (fn [s path _ _] + (when (= :multi (m/type s)) + (swap! !acc assoc (clean-path path) (into #{} (map first) (m/children s)))) + s) + {::m/walk-schema-refs true ::m/walk-refs true}) + @!acc)) + +(defn- schema-path-walker + "Walker that fires `map-f` at `:map` nodes and `multi-f` at `:multi` nodes. + `multi-f` also receives the original (pre-walk) schema." + [map-f multi-f] (fn [schema path children _] - (let [schema (m/-set-children schema children) - map-schema? (= :map (m/type schema))] - (cond-> schema - map-schema? (f path))))) - + (let [walked (m/-set-children schema children)] + (case (m/type schema) + :map (map-f walked path) + :multi (multi-f walked schema path) + walked)))) + + +(defn- merge-selections + "Merge two sub-selections for the same key: vectors concatenate, branch-maps + merge per branch. On a shape mismatch the last one wins (verification then + reports the wrong-shaped one)." + [a b] + (cond + (and (map? a) (map? b)) (merge-with merge-selections a b) + (and (sequential? a) (sequential? b)) (into (vec a) b) + :else b)) (defn- sel->map "Turns `[:a {:b [:c]} {:b [:d]} :e]` - into `{nil [:a :e] :b [:d]}` (i.e. last `:b` wins)." + into `{nil [:a :e] :b [:c :d]}` (i.e. duplicate keys merge)." [sel] (persistent! (reduce (fn [acc i] (if (map? i) - (reduce conj! acc i) + (reduce-kv (fn [acc k v] + (assoc! acc k (merge-selections (get acc k []) v))) acc i) (assoc! acc nil (conj (get acc nil) i)))) (transient {}) sel))) @@ -54,18 +104,51 @@ (parse-selection []) ;; => [['?]] (parse-selection [:name]) ;; => [[:name]] (parse-selection [:name {:address [:street]}]) ;; => [[:name] [:address :street]] + (parse-selection {:human [:name]}) ;; => [[{:branch :human} :name]] ``` - " + A selection that *is* a map addresses branches of a `:multi`: keys are + dispatch values (`'*` for every branch), values are sub-selections." ([sel] (parse-selection sel [])) ([sel path] - (if-not (seq sel) + (cond + (not (seq sel)) [(conj path '?)] + + (map? sel) + (persistent! + (reduce-kv (fn [acc k v] + (reduce conj! acc (parse-selection v (conj path {:branch k})))) + (transient []) sel)) + + :else (let [sel-map (sel->map sel)] (persistent! (reduce-kv (fn [acc k v] (if (nil? k) (reduce conj! acc (map #(conj path %) v)) (reduce conj! acc (parse-selection v (conj path k))))) (transient []) sel-map)))))) +(comment + (sel->map {'* []}) + (parse-selection {'* []}) + (select [:vector [:multi {:dispatch :type} [:foo [:map [:f :string]]] [::m/default [:map [:d :string]]]]] {::m/default [:d]}) + #_:end) + +(defn- expand-star-branches + "Expand every `{:branch '*}` segment of `path` into one path per branch of + the `:multi` at that position. Positions without a known `:multi` keep the + star segment (so verification reports it)." + [multi-paths path] + (loop [expanded [[]] [seg & more :as segs] (seq path)] + (if-not segs + expanded + (recur (if (= {:branch '*} seg) + (vec (mapcat (fn [p] + (if-let [ks (seq (get multi-paths (untag-path p)))] + (map #(conj p {:branch %}) ks) + [(conj p seg)])) + expanded)) + (mapv #(conj % seg) expanded)) + more)))) (defn- paths->tree [paths] @@ -75,7 +158,8 @@ (defn selectable-paths - "Yield set of selectable paths. + "Yield set of selectable paths. Branches of a `:multi` show up as + `{:branch dispatch-value}` segments. Examples: ``` @@ -88,16 +172,51 @@ ``` " [schema] - (->> schema - mu/subschemas - (map (comp clean-path :path)) - (filter seq) - set)) + (let [subs (mu/subschemas schema) + multis (into {} + (keep (fn [{s :schema p :path}] + (when (= :multi (m/type s)) + [(clean-path p) (into #{} (map first) (m/children s))]))) + subs)] + (->> subs + (map (comp #(tag-path multis %) clean-path :path)) + (filter seq) + set))) + + +(defn- star-available + "For every failing `'*`-branch selection in `origin-paths`: the attribute + paths available in *all* branches of the `:multi` at that position, spelled + as `{:branch '*}` paths." + [multi-paths available origin-paths] + (for [p origin-paths + :let [idx (first (keep-indexed (fn [i seg] (when (= {:branch '*} seg) i)) p))] + :when idx + :let [prefix (subvec (vec p) 0 idx) + branch-ks (get multi-paths (untag-path prefix)) + per-branch (map (fn [k] + (let [bp (conj prefix {:branch k})] + (into #{} (keep #(when (and (seq %) (= bp (pop %))) (peek %))) available))) + branch-ks) + common (if (seq per-branch) (apply set/intersection per-branch) #{})] + attr common] + (conj prefix {:branch '*} attr))) + +(defn- shape-mismatch? + "True when `path` addresses a `:multi` position without a `{:branch _}` + segment, or a non-multi position with one." + [multi-paths path] + (boolean + (loop [prefix [] [seg & more :as segs] (seq path)] + (when segs + (if (= (contains? multi-paths (untag-path prefix)) (branch-seg? seg)) + (recur (conj prefix seg) more) + true))))) (defn- -select [schema selection - {:as _options + {:as options ::keys [optionalized] :keys [verify-selection prune-optionals] :or @@ -110,7 +229,15 @@ prune-optionals (if (not (nil? prune-optionals)) prune-optionals (-> selection meta :only)) - selection-paths (parse-selection selection) + multi-paths (when-not all-optional? + (or (::multi-paths options) (multi-branch-keys schema))) + expanded->origin (into {} + (mapcat (fn [p] + (if (some #{{:branch '*}} p) + (map #(vector % p) (expand-star-branches multi-paths p)) + [[p p]]))) + (parse-selection selection)) + selection-paths (keys expanded->origin) sel-map (paths->tree selection-paths) !available-paths (atom #{}) !seen (atom #{}) @@ -132,7 +259,7 @@ walker (let [optionalize-step (fn optionalize-step [v] (update v 0 mu/optional-keys)) require-step (fn require-step [[schema path :as v]] - (let [cleaned-path (clean-path path) + (let [cleaned-path (tag-path multi-paths (clean-path path)) to-require (sel-map cleaned-path)] (if-not (seq to-require) v @@ -156,15 +283,75 @@ (not optionalized) (wrap optionalize-step) (not all-optional?) (wrap require-step) prune-optionals (wrap prune-step) - :finally (wrap first))] - (map-schema-path-walker (comp middlewares vector))) + :finally (wrap first)) + + ;; a `'?` selection at a :multi ({:pet {}}, [{:pet []}]) mentions + ;; the multi: mark it seen and protect it from pruning. + mention-step (fn mention-step [schema path] + (when-not all-optional? + (let [cleaned (tag-path multi-paths (clean-path path)) + to-require (sel-map cleaned)] + (when (and to-require (to-require '?)) + (when verify-selection? + (swap! !seen conj (conj cleaned '?))) + (record-prune-exclusions! path)))) + schema) + multi-prune-step (fn multi-prune-step [schema path] + (let [children (m/children schema) + mentioned (filterv #(contains? @!prune-exclusions (conj path (first %))) + children)] + (if (seq mentioned) + (m/into-schema (m/type schema) (m/-properties schema) + mentioned (m/-options schema)) + schema))) + ;; a keyword :dispatch key must stay required in every explicit + ;; map-branch - an "optional" dispatch key is a lie in the form. + ;; `::m/default` and nil branches also match on an *absent* + ;; dispatch key, so they are left alone. + dispatch-require-step + (fn dispatch-require-step [schema original] + (let [dispatch (:dispatch (m/properties schema))] + (if-not (keyword? dispatch) + schema + (let [orig-entry (fn [dv] + (some (fn [[dv' _ branch]] + (when (= dv' dv) + (some (fn [[k p s]] (when (= k dispatch) [p s])) + (when (= :map (m/type branch)) (m/children branch))))) + (m/children original))) + fix-child (fn [[dv props branch :as child]] + (if (or (not= :map (m/type branch)) + (= ::m/default dv) + (nil? dv)) + child + (if (mu/get branch dispatch) + [dv props (mu/required-keys branch [dispatch])] + (if-let [[eprops eschema] (orig-entry dv)] + [dv props (mu/assoc branch [dispatch (not-empty (dissoc eprops :optional))] eschema)] + child))))] + (m/into-schema (m/type schema) (m/-properties schema) + (mapv fix-child (m/children schema)) (m/-options schema)))))) + multi-step (fn multi-step [walked original path] + (-> walked + (mention-step path) + (cond-> prune-optionals (multi-prune-step path)) + (dispatch-require-step original)))] + (schema-path-walker (comp middlewares vector) multi-step)) walked (m/walk schema walker {::m/walk-schema-refs true ::m/walk-refs true})] (when verify-selection? (let [invalid-selection-paths (remove @!seen selection-paths)] (when (seq invalid-selection-paths) - (let [report {:paths invalid-selection-paths - :available (sort-by pr-str (selectable-paths schema))}] + (let [origin-paths (distinct (map #(get expanded->origin % %) invalid-selection-paths)) + available (selectable-paths schema) + report (cond-> {:paths origin-paths + :available (sort-by pr-str + (into available + (star-available multi-paths available origin-paths)))} + (some #(shape-mismatch? multi-paths %) origin-paths) + (assoc :hint (str "a vector selects attributes of a map-schema, " + "a map selects branches of a multi-schema, " + "e.g. {:some-branch [:name]}")))] (cond (fn? verify-selection) (verify-selection report) (= :log verify-selection) (-warn! ::unknown-paths report) @@ -183,7 +370,18 @@ Combinations: - `[:address {:address [:street]}]` - require `:address` but only its `:street` is required. - `[:address {:address [] :friends [:name]}]` - require `:address` and optionally `:friends`. - - `[{:friends [:name]} {:friends [:age]}]` - only require `:age` of friends if `:friends` provided (last selection wins). + - `[{:friends [:name]} {:friends [:age]}]` - require `:name` and `:age` of friends if `:friends` provided (selections merge). + + `:multi` schemas are selected with a *map*: keys are dispatch values, values + are sub-selections. Unmentioned branches become all-optional. A keyword + `:dispatch` key stays required in every explicit map-branch: + - `{:human [:name]}` - require `:name` in branch `:human`. + - `{'* [:type]}` - require `:type` in *every* branch (fails verification when a branch lacks it). + - `{'* [:type] :human [:name]}` - `'*` merges with explicit branches. + - `{::m/default [:x]}` - address the default branch. + - `{}` - everything optional (like `[]`). + - `[:id {:pet {:dog [:breed]}}]` - nested: `:pet` is a `:multi` with a `:dog` branch. + With `prune-optionals`: branches stay when mentioned (or when none is mentioned); other branches are dropped. `options`: - `verify-selection` - what to do when `selection` contains paths not in `schema`. Defaults to `*verify-selection*` (initially `:throw`): @@ -228,11 +426,13 @@ ``` " [schema] - (let [optionalized-schema (select schema)] + (let [optionalized-schema (select schema) + multi-paths (multi-branch-keys optionalized-schema)] (fn selector-select ([selection] (selector-select selection nil)) ([selection options] - (-select optionalized-schema selection (merge {::optionalized true} options)))))) + (-select optionalized-schema selection + (merge {::optionalized true ::multi-paths multi-paths} options)))))) (comment (def Person diff --git a/test/malli_select/core_test.cljc b/test/malli_select/core_test.cljc index cd8d52e..7bf252a 100644 --- a/test/malli_select/core_test.cljc +++ b/test/malli_select/core_test.cljc @@ -109,8 +109,11 @@ (expect-selection-to-invalidate [{:roles [:name]}] {:roles #{{}}}) (expect-selection-to-validate [{:roles [:name]}] {:roles #{{:name "Admin"}}}) - (expect-selection-to-validate [{:address [:street]} {:address [:zip]}] {:address {:zip 1234}} - "last selection of :address wins") + (expect-selection-to-invalidate [{:address [:street]} {:address [:zip]}] {:address {:zip 1234}} + "selections for :address merge - :street also required") + (expect-selection-to-validate [{:address [:street]} {:address [:zip]}] + {:address {:street "Main" :zip 1234}} + "selections for :address merge") (expect-selection-to-invalidate [{:address [:street] :roles [:name]} {:address [:zip]}] @@ -118,8 +121,8 @@ "selection maps are merged") (expect-selection-to-validate [{:address [:street] :roles [:name]} {:address [:zip]}] - {:address {:zip 1234} :roles #{{:name "Admin"}}} - "[:address :street] is overridden by [:address :zip]") + {:address {:street "Main" :zip 1234} :roles #{{:name "Admin"}}} + "[:address :street] merges with [:address :zip]") (expect-selection-to-validate [{:address [{:country [:name]}]}] {:address {:country {"DK" {:name "Denmark"}}}} @@ -243,6 +246,131 @@ {:name "Foo" :age "NaN"} "All but :name optional"))) +(deftest multi-select-test + (testing "branch selections" + (binding [*schema* [:multi {:dispatch :type} + [:foo [:map [:f :string] [:fo :string]]] + [:bar [:map [:b :string] [:ba :string]]] + [::m/default [:map [:d :string] [:de :string]]]]] + (expect-selection-to-validate {} + {} + "everything (in default-branch) is optional") + (expect-selection-to-validate [] + {} + "everything (in default-branch) is optional") + (expect-selection-to-validate {:foo [:f]} + {} + "only the foo-branch has required atts") + (expect-selection-to-invalidate {:foo [:f]} + {:type :foo} + "foo-branch requires :f") + (expect-selection-to-validate {:foo [:f]} + {:type :foo :f ""} + "foo-branch requires only f") + (expect-selection-to-validate {:foo ['*]} + {:type :foo :f "" :fo ""} + "whole foo-branch required") + (expect-selection-to-invalidate {:foo ['*]} + {:type :foo :f ""} + "whole foo-branch required") + (expect-selection-to-invalidate {::m/default [:d]} + {} + "default-branch requires :d") + (expect-selection-to-validate {::m/default [:d]} + {:d ""} + "default-branch requires :d"))) + + (testing "dispatch key stays required" + (binding [*schema* [:multi {:dispatch :type} + [:human [:map [:type :keyword] [:name :string] [:age :int]]] + [:sized [:map [:type :keyword] [:size :int]]]]] + (expect-selection-to-validate {} {:type :human}) + (expect-selection-to-invalidate {} {:name "x"} + "dispatch key :type is auto-required") + + (testing "'* addresses all branches" + (expect-selection-to-validate {'* [:type]} {:type :sized}) + (expect-selection-to-validate {'* [:type] :human [:name]} + {:type :human :name "x"} + "'* merges with explicit branches") + (expect-selection-to-invalidate {'* [:type] :human [:name]} + {:type :human} + "'* merges with explicit branches") + + (let [ex-data (try + (select *schema* {'* [:name]}) + (catch #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core/ExceptionInfo) e (ex-data e)))] + (is (= ::sut/unknown-paths (:type ex-data)) + "'*-selections must be satisfiable in every branch") + (is (= [[{:branch '*} :name]] (:paths (:data ex-data)))) + (is (some #{[{:branch '*} :type]} (:available (:data ex-data))) + "attributes common to all branches are '*-available")) + + (let [s (select *schema* {'* [:name]} {:verify-selection false})] + (is (true? (m/validate s {:type :human :name "x"}))) + (is (false? (m/validate s {:type :human})) + ":name required where it exists") + (is (true? (m/validate s {:type :sized})) + ":name is a no-op for branches lacking it"))) + + (testing "selection shape must mirror schema shape" + (let [ex-data (try + (select *schema* [:name]) + (catch #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core/ExceptionInfo) e (ex-data e)))] + (is (= ::sut/unknown-paths (:type ex-data)) + "a vector selection can't target branches") + (is (some? (:hint (:data ex-data))))) + (is (thrown? #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core/ExceptionInfo) + (select *schema* {:baz [:type]})) + "unknown branch") + (let [ex-data (try + (select [:map [:name :string]] {:name []}) + (catch #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core/ExceptionInfo) e (ex-data e)))] + (is (some? (:hint (:data ex-data))) + "a map selection can't target attributes of a map-schema"))) + + (testing "pruning drops unmentioned branches" + (expect-selection-to-validate ^:only {:human [:name]} {:type :human :name "x"}) + (expect-selection-to-invalidate ^:only {:human [:name]} {:type :sized :size 1} + ":sized-branch is dropped") + (expect-selection-to-invalidate ^:only {:human [:name]} {:name "x"} + "dispatch key :type stays required") + (expect-selection-to-validate ^:only {} {:type :sized} + "no branch mentioned: all branches stay")))) + + (testing "nested multi" + (binding [*schema* [:map + [:id :int] + [:pet [:multi {:dispatch :kind} + [:dog [:map [:kind :keyword] [:breed :string]]] + [:cat [:map [:kind :keyword] [:lives :int]]]]]]] + (expect-selection-to-validate [:id {:pet {:dog [:breed]}}] {:id 1} + ":pet stays optional") + (expect-selection-to-invalidate [:id {:pet {:dog [:breed]}}] {:id 1 :pet {:kind :dog}}) + (expect-selection-to-validate [:id {:pet {:dog [:breed]}}] {:id 1 :pet {:kind :dog :breed "lab"}}) + (expect-selection-to-validate [:id {:pet {:dog [:breed]}}] {:id 1 :pet {:kind :cat}} + "unmentioned :cat-branch is all-optional") + (expect-selection-to-validate [{:pet {}}] {} + "mentioning a multi without selecting from it is allowed"))) + + (testing "function dispatch: no auto-required key" + (binding [*schema* [:multi {:dispatch (fn [x] (if (:name x) :named :anon))} + [:named [:map [:name :string]]] + [:anon [:map [:id :int]]]]] + (expect-selection-to-validate {:named [:name]} {:name "x"}) + (expect-selection-to-validate {:named [:name]} {} + ":anon-branch is all-optional"))) + + (testing "selector" + (binding [*selector* (selector [:multi {:dispatch :type} + [:human [:map [:type :keyword] [:name :string]]] + [:sized [:map [:type :keyword] [:size :int]]]])] + (expect-selection-to-validate {:human [:name]} {:type :human :name "x"}) + (expect-selection-to-invalidate {:human [:name]} {:type :human}) + (expect-selection-to-validate ^:only {:human [:name]} {:type :human :name "x"}) + (expect-selection-to-invalidate ^:only {:human [:name]} {:type :sized :size 1} + ":sized-branch is dropped")))) + (comment (select [:map [:address [:map [:street string?]]]] [{:address [:street]}] {:prune-optionals true})