From 0453024f4072febf62c274e12ac98618b924c9c9 Mon Sep 17 00:00:00 2001 From: Gert Goet Date: Sun, 9 Aug 2026 21:25:09 +0200 Subject: [PATCH] Add select-only Explicit alternative to ^:only metadata / {:prune-optionals true}: forces pruning (a caller-supplied :prune-optionals false is overridden), all other options pass through. Both old spellings keep working; select-only becomes the documented way. Adds test.check to the test aliases for the generation smoke test. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 +++ README.md | 12 +++++----- deps.edn | 6 +++-- src/malli_select/core.cljc | 21 +++++++++++++++-- test/malli_select/core_test.cljc | 40 ++++++++++++++++++++++++++++++-- 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd1b96..6f86f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- `select-only`: explicit alternative to `^:only` metadata / + `{:prune-optionals true}`; forces pruning (also when the caller passes + `:prune-optionals false`). - `: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 diff --git a/README.md b/README.md index bbb725d..f963fa5 100644 --- a/README.md +++ b/README.md @@ -62,16 +62,16 @@ user=> (p (ms/select Person ['*])) ;; remove any optional attribute -user=> (p (ms/select Person [{:addresses ['*]}] {:prune-optionals true})) -;; or shorter: -user=> (p (ms/select Person ^:only [{:addresses ['*]}])) +user=> (p (ms/select-only Person [{:addresses ['*]}])) ;; example valid data: ;; {:name :not-a-string} ;; ;; Typically you'd use this to generate only specific data: user=> (require '[malli.generator :as mg]) -user=> (mg/generate (ms/select Person ^:only [:name])) +user=> (mg/generate (ms/select-only Person [:name])) {:name "sNeLdUI5KtPw"} +;; also supported: (ms/select Person [:name] {:prune-optionals true}) +;; and, via metadata: (ms/select Person ^:only [:name]) ;; selecting something not contained in the schema: user=> (ms/select Person [:a]) @@ -150,9 +150,9 @@ user=> (def Person [:map [:id :int] [:pet [:multi {:dispatch :kind} [:cat [:map [:kind :keyword] [:lives :int]]]]]]) user=> (p (ms/select Person [:id {:pet {:dog [:breed]}}])) -;; pruning drops unmentioned branches (mentioning none keeps all), +;; select-only drops unmentioned branches (mentioning none keeps all), ;; e.g. to generate only humans: -user=> (mg/generate (ms/select Animal ^:only {:human [:name]})) +user=> (mg/generate (ms/select-only Animal {:human [:name]})) {:name "x2Ep", :type :human} ``` diff --git a/deps.edn b/deps.edn index 3653294..9af7f2a 100644 --- a/deps.edn +++ b/deps.edn @@ -29,13 +29,15 @@ :test ;; added by neil {:extra-paths ["test"] - :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}} + :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"} + org.clojure/test.check {:mvn/version "1.1.1"}} :exec-fn cognitect.test-runner.api/test} :cljs-test ;; run tests on Node: clojure -M:cljs-test {:extra-paths ["test"] :extra-deps {org.clojure/clojurescript {:mvn/version "1.12.42"} - olical/cljs-test-runner {:mvn/version "3.8.1"}} + olical/cljs-test-runner {:mvn/version "3.8.1"} + org.clojure/test.check {:mvn/version "1.1.1"}} :main-opts ["-m" "cljs-test-runner.main"]} :perf {#_#_:extra-paths ["perf"] :extra-deps {criterium/criterium {:mvn/version "0.4.6"} diff --git a/src/malli_select/core.cljc b/src/malli_select/core.cljc index 6f54510..8d363cd 100644 --- a/src/malli_select/core.cljc +++ b/src/malli_select/core.cljc @@ -389,7 +389,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). + - `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. Typically used when the selected schema is used for data generation. Examples: @@ -415,6 +415,23 @@ :prune-optionals prune-optionals)))) +(defn select-only + "Like `select`, but the result contains only the selected attributes: + `:prune-optionals` is forced to `true` (a caller-supplied + `:prune-optionals false` is overridden). All other options (e.g. + `:verify-selection`) pass through unchanged. + + Typically used to generate specific data: + ``` + (mg/generate (select-only Person [:name])) ;; => {:name \"...\"} + ``` + For `:multi` schemas, branches the selection does not mention are dropped." + ([schema selection] + (select-only schema selection nil)) + ([schema selection options] + (select schema selection (assoc options :prune-optionals 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. @@ -422,7 +439,7 @@ Examples: ``` (let [person-selector (selector Person)] - (person-selector ^:only [:name])) + (person-selector [:name] {:prune-optionals true})) ;; what `select-only` does ``` " [schema] diff --git a/test/malli_select/core_test.cljc b/test/malli_select/core_test.cljc index 7bf252a..ce3193e 100644 --- a/test/malli_select/core_test.cljc +++ b/test/malli_select/core_test.cljc @@ -5,8 +5,9 @@ (:require [clojure.pprint :refer [pprint]] [clojure.test :as t :refer [deftest is testing]] - [malli-select.core :as sut :refer [select selector]] - [malli.core :as m])) + [malli-select.core :as sut :refer [select select-only selector]] + [malli.core :as m] + [malli.generator :as mg])) (defonce ^:private ^:dynamic *schema* nil) @@ -371,6 +372,41 @@ (expect-selection-to-invalidate ^:only {:human [:name]} {:type :sized :size 1} ":sized-branch is dropped")))) +(deftest select-only-test + (let [Person [:map + [:name :string] + [:age :int] + [:addresses [:vector [:map [:street :string] [:zip :int]]]]]] + (testing "same result as select with pruning" + (is (= (m/form (select Person [:name] {:prune-optionals true})) + (m/form (select-only Person [:name])))) + (is (= (m/form (select Person ^:only [:name])) + (m/form (select-only Person [:name]))))) + + (testing ":prune-optionals is forced" + (is (= (m/form (select-only Person [:name])) + (m/form (select-only Person [:name] {:prune-optionals false}))))) + + (testing "other options pass through" + (let [ex-data (try + (select-only Person [:nope]) + (catch #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core/ExceptionInfo) e (ex-data e)))] + (is (= ::sut/unknown-paths (:type ex-data)))) + (is (some? (select-only Person [:nope] {:verify-selection false})))) + + (testing "multi: unmentioned branches are dropped" + ;; the pruned dispatch key gets re-appended, hence :name before :type + (is (= [:multi {:dispatch :type} + [:human [:map [:name :string] [:type :keyword]]]] + (m/form (select-only [:multi {:dispatch :type} + [:human [:map [:type :keyword] [:name :string] [:age :int]]] + [:sized [:map [:type :keyword] [:size :int]]]] + {:human [:name]}))))) + + (testing "generated samples validate against the pruned schema" + (let [s (select-only Person [:name {:addresses [:street]}])] + (is (every? (partial m/validate s) (mg/sample s))))))) + (comment (select [:map [:address [:map [:street string?]]]] [{:address [:street]}] {:prune-optionals true})