Repository: metosin/malli
Pull request #1208: Gen min max for :+ and :* sequences
Author: login: mattiuusitalo
Created: 2025-05-21T10:26:18Z | Merged: 2025-05-23T05:47:21Z

This is a (partial) fix for #1168

It makes sure that `:+` and `:*` use the :gen/min :gen/max etc options that are supported by other collection schemas.

I think it fixes the issue that the OG reporter had. 


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c513d860f..a4c5a8387 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ Malli is in well matured [alpha](README.md#alpha).
 * **BREAKING** FIX: `json-transformer` is now better at inferring en/decoders for `:enum` and `:=`. [#1205](https://github.com/metosin/malli/pull/1205)
   * For example `[:enum 1 2 3]` gets encoded as a JSON number, not a string.
   * If you need the old behaviour, you can override the en/decoders using properties. [See docs.](README.md#advanced-transformations)
+* Value generation: `:+` and `:*` now support generator directives `:gen/min`, `:gen/max` as well as `:min` and `:max` schema directives [#1208](https://github.com/metosin/malli/pull/1208)
 * FIX: `:default/fn` now works in map entry properties in addition to schema properties [#1209](https://github.com/metosin/malli/pull/1209)
 
 ## 0.18.0 (2025-05-12)
diff --git a/README.md b/README.md
index 96bf6083b..9d1c2997c 100644
--- a/README.md
+++ b/README.md
@@ -2162,6 +2162,23 @@ Schemas can be used to generate values:
   {:seed 1})
 ; => [-8522515 -1433 -1 1]
 
+;; :gen/min & gen/max works for :+ and :* as well
+(mg/generate
+ [:+ {:gen/min 2 :gen/max 10} :int]
+ {:seed 10})
+; => [-109024846 -2 25432]
+
+;; When composing sequence schemas, the directives effect the definition they are
+;; associated with, such that:
+(mg/generate
+ [:* {:gen/min 2 :gen/max 3} ; 2 - 3 repetitions of
+  [:cat
+   [:+ {:gen/min 2 :gen/max 3} :int] ; 2 - 3 repetitions of int
+   [:* {:gen/min 1 :gen/max 2} :string]]] ; followed by 1-2 repetitions of string
+ {:seed 10})
+
+; => (-812 1283 "Q9beps1Yn3c3VP9" "4XHdn1mgudSlNpVyxOrQIiR5pd5ocs" 114 -14284153 "8SSR9033czAO05")
+
 ;; :gen/infinite? & :gen/NaN? for :double
 (mg/generate
   [:double {:gen/infinite? true, :gen/NaN? true}]
diff --git a/src/malli/generator.cljc b/src/malli/generator.cljc
index 545592890..c3477668a 100644
--- a/src/malli/generator.cljc
+++ b/src/malli/generator.cljc
@@ -42,7 +42,7 @@
 ;;
 ;; Use `-unreachable-gen?` to test whether your child generator generates no values (we'll call this an "unreachable" schema/generator).
 ;; If your parent generator cannot generate values, use `-never-gen` to return an unreachable generator.
-;; 
+;;
 ;; Here are a few examples---compare them with the logic in their respective -schema-generator methods:
 ;;   [:maybe M] would generate like :nil if M were unreachable.
 ;;   [:map [:a M]] would itself be unreachable if M were unreachable.
@@ -210,8 +210,8 @@
 ;;
 ;; For infinitely expanding schemas, this will return an unreachable generator--when the base case generator is used,
 ;; the error message in `-never-gen` will advise users that their schema is infinite.
-;; 
-;; 
+;;
+;;
 ;; Examples of base cases of some recursive schemas:
 ;;
 ;; Schema:    [:schema {:registry {::cons [:maybe [:vector [:tuple pos-int? [:ref ::cons]]]]}} ::cons]
@@ -233,11 +233,11 @@
 ;; supplied by `gen/recursive-gen` to convert recursive references.
 
 ;; ## Identifying schema recursion
-;; 
+;;
 ;; Refs are uniquely identified by their paired name and scope. If we see a ref with the
 ;; same name and scope as another ref we've dereferenced previously, we know that this is a recursion
 ;; point back to the previously seen ref. The rest of this section explains why.
-;; 
+;;
 ;; Refs resolve via dynamic scope, which means its dereferenced value is the latest binding found
 ;; while expanding the schema until the point of finding the ref.
 ;; This makes the (runtime) scope at the ref's location part of a ref's identity---if the scope
@@ -245,7 +245,7 @@
 ;; transitively expand.
 ;;
 ;; To illustrate why a ref's name is an insufficient identifier, here is a schema that is equivalent to `[:= 42]`:
-;; 
+;;
 ;;   [:schema {:registry {::a [:schema {:registry {::a [:= 42]}}
 ;;                             ;; (2)
 ;;                             [:ref ::a]]}}
@@ -277,7 +277,7 @@
 ;; we choose that identifier. The more general insight is that any schema is identified by its form+scope
 ;; (note: but only after trimming the scope of irrelevant bindings, see next pararaph).
 ;; That insight may be useful for detecting recursion at places other than refs.
-;; 
+;;
 ;; Ref identifiers could be made smarter by trimming irrelevant entries in identifying scope.
 ;; Not all scope differences are relevant, so generators may expand more than strictly necessary
 ;; in the quest to find the "same" ref schema again. It could skip over refs that generate exactly the
@@ -335,11 +335,20 @@
 
 (defn -*-gen [schema options]
   (let [child (-child schema options)]
-    (cond->> (gen-vector (when (= :+ (::-*-gen-mode options)) {:min 1}) (generator child (dissoc options ::-*-gen-mode)))
+    (cond->> (gen-vector
+              (-min-max schema options)
+              (generator child options))
       (m/-regex-op? child) gen-fcat)))
 
 (defn -+-gen [schema options]
-  (-*-gen schema (assoc options ::-*-gen-mode :+)))
+  (let [child (-child schema options)]
+    (cond->> (gen-vector
+              (-> (-min-max schema options)
+                  ;; When generating from :+ the base minimum value must be 1
+                  ;; to ensure that :+ is always fulfilled
+                  (update :min (fnil max 1)))
+              (generator child options))
+      (m/-regex-op? child) gen-fcat)))
 
 (defn -repeat-gen [schema options]
   (or (some-> (-coll-gen schema options) -not-unreachable (cond-> (m/-regex-op? (-child schema options)) gen-fcat))
diff --git a/test/malli/generator_test.cljc b/test/malli/generator_test.cljc
index dc1315dbc..d797c02d1 100644
--- a/test/malli/generator_test.cljc
+++ b/test/malli/generator_test.cljc
@@ -314,10 +314,23 @@
       [:set {:min 10, :gen/max 20} int?]
       [:string {:gen/min 10, :max 20}]
 
+      [:+ {:min 10 :max 20} :int]
+      [:+ {:gen/min 10 :max 20} :int]
+      [:+ {:min 10 :gen/max 20} :int]
+      [:+ {:gen/min 10 :gen/max 20} :int]
+
+      [:* {:min 10 :max 20} :int]
+      [:* {:gen/min 10 :max 20} :int]
+      [:* {:min 10 :gen/max 20} :int]
+      [:* {:gen/min 10 :gen/max 20} :int]
+
       [:vector {:min 1, :gen/min 10, :max 100, :gen/max 20} int?]
       [:set {:min 1, :gen/min 10, :max 100, :gen/max 20} int?]
       [:string {:min 1, :gen/min 10, :max 100, :gen/max 20}]))
 
+  (testing ":+ enforces a minimum count of 1 generated elements"
+    (every? #(<= 1 %) (map count (mg/sample [:+ :int] {:size 1000}))))
+
   (testing "invalid properties"
     (are [schema]
       (thrown? #?(:clj Exception, :cljs js/Error) (mg/sample schema {:size 1000}))


Reviews:
user: login: opqdonut
body: also, do you think we have enough docs so that this behaviour is discoverable? or should we add an example?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T05:36:23Z
user: login: mattiuusitalo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T05:45:16Z
user: login: opqdonut
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T05:48:49Z

Review comments:
diff_hunk: @@ -314,10 +314,16 @@
       [:set {:min 10, :gen/max 20} int?]
       [:string {:gen/min 10, :max 20}]
 
+      [:+ {:min 10 :max 20} :int]
+      [:* {:min 10 :max 20} :int]
path: test/malli/generator_test.cljc
user: login: opqdonut
body: I'd like to see tests for gen/min and gen/max as well.
created_at: 2025-05-22T05:35:33Z
author_association: MEMBER
diff_hunk: @@ -314,10 +314,16 @@
       [:set {:min 10, :gen/max 20} int?]
       [:string {:gen/min 10, :max 20}]
 
+      [:+ {:min 10 :max 20} :int]
+      [:* {:min 10 :max 20} :int]
path: test/malli/generator_test.cljc
user: login: mattiuusitalo
body: Good idea, adding those
created_at: 2025-05-22T05:45:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: opqdonut
created_at: 2025-05-22T05:48:59Z
body: don't forget to update the changelog!
author_association: MEMBER
user: login: ikitommi
created_at: 2025-05-22T05:57:59Z
body: how do nested min & max work? e.g.

```clojure
[:cat {:gen/min 2 :gen/max 10} [:+ {:gen/min 4 :gen/max 5} :int] [:* {:gen/min 1 :gen/max 5} :string]]
```
author_association: MEMBER
user: login: opqdonut
created_at: 2025-05-22T05:59:42Z
body: Do we have `:gen/min` support for `:cat` already? This PR doesn't add it AIUI.
author_association: MEMBER
user: login: ikitommi
created_at: 2025-05-22T06:00:46Z
body: ```clojure
[:* {:gen/min 2 :gen/max 10} [:cat [:+ {:gen/min 4 :gen/max 5} :int] [:* {:gen/min 1 :gen/max 5} :string]]]
```

if we don't have `:cat` min-max-gens.
author_association: MEMBER
user: login: mattiuusitalo
created_at: 2025-05-22T06:04:52Z
body: It composes such as it will generate 2...10 repetitions of 4...5 ints and 1...5 strings
author_association: CONTRIBUTOR
user: login: opqdonut
created_at: 2025-05-22T06:12:40Z
body: Somebody might be surprised that the min and max refer to the repetitions of the child schema, not to the size of the resulting sequence, but other than that I think this generalizes nicely.
author_association: MEMBER
user: login: opqdonut
created_at: 2025-05-22T06:15:30Z
body: In that way, the semantics are the same as for regex:

```
[:* {:gen/min 2 :gen/max 10} [:cat [:+ {:gen/min 4 :gen/max 5} :int] [:* {:gen/min 1 :gen/max 5} :string]]]
```

corresponds to `(I{4,5}S{1,5}){2,10}`, where `I` and `S` are stand-ins for regexes for `:int` and `:string`
author_association: MEMBER
user: login: mattiuusitalo
created_at: 2025-05-22T06:16:35Z
body: Let's clarify how it composes in the README
author_association: CONTRIBUTOR