Skip to content
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
2 changes: 2 additions & 0 deletions .clj-kondo/config.edn
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{:skip-comments true
:output {:exclude-files ["cljs-test-runner-out"]}}
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ jobs:
clojure -X:test:malli-${{ matrix.malli }}
;;
esac
Lint:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install clj-kondo
uses: DeLaGuardo/setup-clojure@4c7a6f613e5089821bb3bb2a33a3ee115578580d # 13.6.1
with:
clj-kondo: latest
- name: Run clj-kondo
run: clj-kondo --lint .
Test-cljs:
runs-on: ubuntu-latest
steps:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

### Added

- `prune-optionals`: standalone pruning of any schema — removes every optional subtree
that contains no required attribute. Takes an optional keep-selection
(positional, selection notation): mentioned entries are kept but stay
optional. For `:multi` schemas, mentioning branches keeps only those
branches. See the README's "Pruning" section.
- `select-only`: explicit alternative to `^:only` metadata /
`{:prune-optionals true}`; forces pruning (also when the caller passes
`:prune-optionals false`).
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,54 @@ Limitations:
- A function-valued `:dispatch` disables the dispatch-key auto-require.


## Pruning

`select-only` needs schema and selection at hand. When a schema already *is*
a selection — or is hand-written with optional keys — use `prune-optionals`: it removes
every optional subtree that contains no required attribute.

``` clojure
user=> (def Selected
(ms/select [:map
[:name string?]
[:age pos-int?]
[:addresses [:vector [:map
[:street string?]
[:zip string?]]]]]
[:name]))

user=> (p (ms/prune-optionals Selected))
[:map [:name string?]]

;; the second argument is a *keep*-selection: it reuses the selection
;; notation, but mentioned entries are KEPT, not required - they stay optional:
user=> (p (ms/prune-optionals Selected [{:addresses []}]))
[:map
[:name string?]
[:addresses {:optional true} [:vector :map]]]

user=> (p (ms/prune-optionals Selected [{:addresses [:street]}]))
[:map
[:name string?]
[:addresses {:optional true}
[:vector [:map [:street {:optional true} string?]]]]]

;; multi-schemas: mentioning one or more branches keeps only those branches,
;; mentioning none keeps all. (Without this rule a pruned Animal would keep
;; generating bare `{:type :sized}` samples.)
user=> (p (ms/prune-optionals Animal {:human []}))
[:multi {:dispatch :type}
[:human [:map [:type :keyword] [:name string?] [:age pos-int?]]]]
```

Optional entries whose subtree contains a required attribute survive a prune —
e.g. hand-written `[:addresses {:optional true} [:map [:street :string]]]`
keeps `:addresses` (and its required `:street`).

Related: `(ms/select-only S sel)` ≡ `(ms/prune-optionals (ms/select S sel) sel)` — the
same selection acts as require-spec, then keep-spec.


## LICENSE

Copyright (c) 2026 Gert Goet, ThinkCreate.
Expand Down
104 changes: 92 additions & 12 deletions src/malli_select/core.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@
{::m/walk-schema-refs true ::m/walk-refs true})
@!acc))

(defn- multi-dispatch-paths
"Map of raw path → dispatch key, for every `:multi` in `schema` with a
keyword `:dispatch`."
[schema]
(let [!acc (atom {})]
(m/walk schema
(fn [s path _ _]
(when (= :multi (m/type s))
(let [dispatch (:dispatch (m/properties s))]
(when (keyword? dispatch)
(swap! !acc assoc path dispatch))))
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."
Expand Down Expand Up @@ -217,7 +232,7 @@
(defn- -select
[schema selection
{:as options
::keys [optionalized]
::keys [optionalized keep-only]
:keys [verify-selection prune-optionals]
:or
{verify-selection *verify-selection*}}]
Expand Down Expand Up @@ -255,6 +270,8 @@
(when prune-optionals
(let [self&parent-paths (take (inc (count path)) (iterate pop path))]
(swap! !prune-exclusions into self&parent-paths))))
multi-dispatches (when keep-only (multi-dispatch-paths schema))
!required-below (atom #{})

walker (let [optionalize-step (fn optionalize-step [v]
(update v 0 mu/optional-keys))
Expand All @@ -266,24 +283,48 @@
(let [star? (some #{'*} to-require)]
(record-seen! schema cleaned-path to-require)
(record-prune-exclusions! path)
(update v 0
#(if star?
(mu/required-keys %)
(mu/required-keys % to-require)))))))
(if keep-only
;; keep, don't require: protect the mentioned
;; keys themselves (they stay optional).
(let [ks (if star?
(map first (m/entries schema))
(remove #{'?} to-require))]
(swap! !prune-exclusions into (map (partial conj path) ks))
v)
(update v 0
#(if star?
(mu/required-keys %)
(mu/required-keys % to-require))))))))

prune-step (fn prune-step [[schema path :as v]]
(let [prunable? (every-pred (comp :optional second)
(comp not @!prune-exclusions #(conj path %) first))
(let [prunable? (cond-> (every-pred (comp :optional second)
(comp not @!prune-exclusions #(conj path %) first))
;; standalone prune: keep optional entries
;; whose subtree has a required attribute.
keep-only
(every-pred (comp not @!required-below #(conj path %) first)))
children (remove prunable? (m/children schema))]
(update v 0 #(m/into-schema (m/type %) (m/-properties %)
children (m/-options %)))))
;; bottom-up: mark this map (and its ancestors) when it has a
;; required entry. The dispatch key of a `:multi` branch doesn't
;; count: it is required in any well-formed branch.
required-record-step
(fn required-record-step [[schema path :as v]]
(let [dispatch (when (seq path) (get multi-dispatches (pop path)))]
(when (some (fn [[k props _]]
(and (not (:optional props)) (not= k dispatch)))
(m/children schema))
(swap! !required-below into (take (inc (count path)) (iterate pop path)))))
v)
wrap (fn [stack step]
#(step (stack %)))
middlewares (cond-> identity
(not optionalized) (wrap optionalize-step)
(not all-optional?) (wrap require-step)
prune-optionals (wrap prune-step)
:finally (wrap first))
(not (or optionalized keep-only)) (wrap optionalize-step)
(not all-optional?) (wrap require-step)
prune-optionals (wrap prune-step)
keep-only (wrap required-record-step)
:finally (wrap first))

;; a `'?` selection at a :multi ({:pet {}}, [{:pet []}]) mentions
;; the multi: mark it seen and protect it from pruning.
Expand Down Expand Up @@ -389,7 +430,7 @@
- `:log` - print a warning (stderr on Clojure, `console.warn` on ClojureScript) and continue.
- a function - called with `{:paths ... :available ...}`, result ignored, selection continues.
- `:skip`, `false`, `nil` - don't verify.
- `prune-optionals` (`false` (default), `true`) - whether all fully optional subtrees should be removed from the resulting schema. Alternatively via metadata of selection: `^:only [:name]` (flag takes precedence over metadata). See also `select-only`, which forces this option.
- `prune-optionals` (`false` (default), `true`) - whether all fully optional subtrees should be removed from the resulting schema. Alternatively via metadata of selection: `^:only [:name]` (flag takes precedence over metadata). See also `select-only`, which forces this option, and `prune-optionals`, for pruning an existing schema without selecting.
Typically used when the selected schema is used for data generation.

Examples:
Expand Down Expand Up @@ -432,6 +473,45 @@
(select schema selection (assoc options :prune-optionals true))))


(defn prune-optionals
"Remove every optional subtree of `schema` that contains no required
attribute. Required entries, and optional entries whose subtree contains
a required attribute, always stay. Typically used to generate samples
without optional noise from a schema that already is a selection.

NOTE: `keep-selection` uses the selection notation of `select`, but it
means KEEP, not require: `(prune-optionals S [:a])` does *not* prune `:a`,
and kept entries stay optional.

For `:multi` schemas the branch rule *subtracts*: mentioning one or more
branches keeps only those branches; mentioning none keeps all. (A keyword
`:dispatch` key stays required in every kept explicit map-branch.)

`options` are those of `select`: `:verify-selection` applies to the
keep-selection; `:prune-optionals` is implied.

Examples:
```
(prune-optionals Selected) ;; only the required spine stays
(prune-optionals Selected [{:addresses []}]) ;; also keep :addresses as empty optional map
(prune-optionals Selected [{:addresses [:street]}]) ;; keep :addresses with an OPTIONAL :street
(prune-optionals Animal {:human []}) ;; keep only branch :human
```
Related: `(select-only S sel)` ≡ `(prune-optionals (select S sel) sel)` —
the same selection acts as require-spec, then keep-spec."
([schema]
(prune-optionals schema [] nil))
([schema keep-selection]
(prune-optionals schema keep-selection nil))
([schema keep-selection {:as options
:keys [verify-selection]
:or {verify-selection *verify-selection*}}]
(-select schema keep-selection (assoc options
:verify-selection verify-selection
:prune-optionals true
::keep-only true))))


(defn selector
"Yields a function similar to `(partial ms/select schema)`.
A selector is faster when doing multiple selections from a schema as the schema is optionalized once.
Expand Down
Loading