From 12b3b5c42122994077390f95c8e340730aedb98d Mon Sep 17 00:00:00 2001 From: Philip Munksgaard Date: Thu, 28 Aug 2025 15:31:29 +0200 Subject: [PATCH 1/7] Add nullable/2 generator As discussed in #220, this PR implements a `nullable/2` generator. It returns either the result of the generator given as the first argument or `nil`. The default ratio between the two possibilities is 0.5, but can be adjusted using the `:ratio` option. Fixes #220 --- lib/stream_data.ex | 46 +++++++++++++++++++++++++++++++++++++++ test/stream_data_test.exs | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/lib/stream_data.ex b/lib/stream_data.ex index a413a7e..b2c582a 100644 --- a/lib/stream_data.ex +++ b/lib/stream_data.ex @@ -2223,6 +2223,52 @@ defmodule StreamData do scale(data, fn size -> trunc(:math.pow(size, exponent)) end) end + @doc """ + Generates either `nil` or the value generated by the given `data`. + + The frequency distribution can be specified using the `ratio` keyword + argument. It defaults to 0.5, meaning that the result will be `nil` half the + time. A ratio of 0.25 means that `nil` is selected 1/4th of the time and + `data` is selected 3/4th of the time. + + ## Options + + * `:ratio` - (float in between 0.0 and 1.0, not included) specifies the + frequency with which `nil` should be selected. Higher means that `nil` is + more likely to be selected. Defaults to 0.5. + + ## Examples + + Enum.take(StreamData.nullable(StreamData.integer()), 10) + #=> [1, -1, nil, nil, 5, 4, -3, nil, nil, 1] + + Enum.take(StreamData.nullable(StreamData.integer(), ratio: 0.25), 10) + #=> [1, nil, -3, nil, 3, -3, 0, 3, nil, nil] + + Enum.take(StreamData.nullable(StreamData.integer(), ratio: 0.25), 10) + #=> [0, -2, 1, nil, -2, 0, -4, 4, 6, nil] + + Enum.take(StreamData.nullable(StreamData.boolean(), ratio: 0.25), 10) + #=> [false, false, false, nil, true, true, true, false, false, true] + """ + @spec nullable(t(a), keyword()) :: t(nil | a) when a: term() + def nullable(generator, opts \\ [ratio: 0.5]) do + ratio = Keyword.get(opts, :ratio) + + {numerator, denominator} = + if 0 < ratio and ratio < 1.0 do + Float.ratio(ratio) + else + raise ArgumentError, """ + expected :ratio to be greater than 0.0 and less than 1.0, got: + + * ratio: #{inspect(ratio)} + """ + end + + StreamData.frequency([{numerator, nil}, {denominator - numerator, generator}]) + end + @doc """ Checks the behaviour of a given function on values generated by `data`. diff --git a/test/stream_data_test.exs b/test/stream_data_test.exs index 1622733..bae9dcd 100644 --- a/test/stream_data_test.exs +++ b/test/stream_data_test.exs @@ -745,6 +745,49 @@ defmodule StreamDataTest do end end + describe "nullable/2" do + test "with invalid options" do + data = constant(:term) + + message = + "expected :ratio to be greater than 0.0 and less than 1.0, got:\n\n* ratio: 1.0\n" + + assert_raise ArgumentError, message, fn -> nullable(data, ratio: 1.0) end + + message = + "expected :ratio to be greater than 0.0 and less than 1.0, got:\n\n* ratio: 0.0\n" + + assert_raise ArgumentError, message, fn -> nullable(data, ratio: 0.0) end + + message = + "expected :ratio to be greater than 0.0 and less than 1.0, got:\n\n* ratio: -0.0\n" + + assert_raise ArgumentError, message, fn -> nullable(data, ratio: -0.0) end + end + + property "returns nil or the value returned by data" do + check all maybe_nil <- nullable(constant(:term)) do + assert is_nil(maybe_nil) or maybe_nil == :term + end + end + + test "with very large chance of nils" do + values = Enum.take(nullable(:small_chance, ratio: 0.99), 1000) + + assert :small_chance in values + assert nil in values + assert Enum.count(values, &(&1 == :small_chance)) < Enum.count(values, &is_nil(&1)) + end + + test "with very small chance of nils" do + values = Enum.take(nullable(:big_chance, ratio: 0.01), 1000) + + assert :big_chance in values + assert nil in values + assert Enum.count(values, &is_nil(&1)) < Enum.count(values, &(&1 == :big_chance)) + end + end + test "check_all/3 with :os.timestamp" do options = [initial_seed: :os.timestamp()] From 653f857204e34f5b953476dce865689cb3cdcd7f Mon Sep 17 00:00:00 2001 From: Philip Munksgaard Date: Fri, 29 Aug 2025 13:19:33 +0200 Subject: [PATCH 2/7] Update lib/stream_data.ex Co-authored-by: Andrea Leopardi --- lib/stream_data.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/stream_data.ex b/lib/stream_data.ex index b2c582a..c3ba42b 100644 --- a/lib/stream_data.ex +++ b/lib/stream_data.ex @@ -2226,10 +2226,7 @@ defmodule StreamData do @doc """ Generates either `nil` or the value generated by the given `data`. - The frequency distribution can be specified using the `ratio` keyword - argument. It defaults to 0.5, meaning that the result will be `nil` half the - time. A ratio of 0.25 means that `nil` is selected 1/4th of the time and - `data` is selected 3/4th of the time. + The frequency distribution can be specified using the `ratio` option. ## Options From 8029ebf8ebd08ace7fc1aabceeab54e287a1e74f Mon Sep 17 00:00:00 2001 From: Philip Munksgaard Date: Fri, 29 Aug 2025 13:20:06 +0200 Subject: [PATCH 3/7] Update lib/stream_data.ex Co-authored-by: Andrea Leopardi --- lib/stream_data.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/stream_data.ex b/lib/stream_data.ex index c3ba42b..a374675 100644 --- a/lib/stream_data.ex +++ b/lib/stream_data.ex @@ -2230,9 +2230,9 @@ defmodule StreamData do ## Options - * `:ratio` - (float in between 0.0 and 1.0, not included) specifies the + * `:ratio` - (float in between `0.0` and `1.0`, not included) specifies the frequency with which `nil` should be selected. Higher means that `nil` is - more likely to be selected. Defaults to 0.5. + more likely to be selected. Defaults to `0.5`. ## Examples From be617cb518ae0c78ba0bb61db9fc38c05b104c70 Mon Sep 17 00:00:00 2001 From: Philip Munksgaard Date: Fri, 29 Aug 2025 13:20:18 +0200 Subject: [PATCH 4/7] Update lib/stream_data.ex Co-authored-by: Andrea Leopardi --- lib/stream_data.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/stream_data.ex b/lib/stream_data.ex index a374675..470294e 100644 --- a/lib/stream_data.ex +++ b/lib/stream_data.ex @@ -2247,6 +2247,7 @@ defmodule StreamData do Enum.take(StreamData.nullable(StreamData.boolean(), ratio: 0.25), 10) #=> [false, false, false, nil, true, true, true, false, false, true] + """ @spec nullable(t(a), keyword()) :: t(nil | a) when a: term() def nullable(generator, opts \\ [ratio: 0.5]) do From e6c07ec921a6a2429fa5d4d15d46f485ef7e9a6b Mon Sep 17 00:00:00 2001 From: Philip Munksgaard Date: Fri, 29 Aug 2025 13:20:28 +0200 Subject: [PATCH 5/7] Update lib/stream_data.ex Co-authored-by: Andrea Leopardi --- lib/stream_data.ex | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/stream_data.ex b/lib/stream_data.ex index 470294e..7426d32 100644 --- a/lib/stream_data.ex +++ b/lib/stream_data.ex @@ -2257,11 +2257,8 @@ defmodule StreamData do if 0 < ratio and ratio < 1.0 do Float.ratio(ratio) else - raise ArgumentError, """ - expected :ratio to be greater than 0.0 and less than 1.0, got: - - * ratio: #{inspect(ratio)} - """ + raise ArgumentError, + "expected :ratio to be greater than 0.0 and less than 1.0, got: #{inspect(ratio)}" end StreamData.frequency([{numerator, nil}, {denominator - numerator, generator}]) From 96588acbeb9909dade3105cda3fc8be312142a84 Mon Sep 17 00:00:00 2001 From: Philip Munksgaard Date: Fri, 29 Aug 2025 13:20:39 +0200 Subject: [PATCH 6/7] Update lib/stream_data.ex Co-authored-by: Andrea Leopardi --- lib/stream_data.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/stream_data.ex b/lib/stream_data.ex index 7426d32..a4bee21 100644 --- a/lib/stream_data.ex +++ b/lib/stream_data.ex @@ -2250,8 +2250,8 @@ defmodule StreamData do """ @spec nullable(t(a), keyword()) :: t(nil | a) when a: term() - def nullable(generator, opts \\ [ratio: 0.5]) do - ratio = Keyword.get(opts, :ratio) + def nullable(generator, options \\ []) when is_list(options) do + ratio = Keyword.get(options, :ratio, 0.5) {numerator, denominator} = if 0 < ratio and ratio < 1.0 do From dac84709b7ef95616a3e131ecd7ff9dd36a4d712 Mon Sep 17 00:00:00 2001 From: Philip Munksgaard Date: Thu, 18 Sep 2025 11:51:55 +0200 Subject: [PATCH 7/7] Relax nullable ratio constraints The nullable ratio is now allowed to be 0.0 and 1.0. --- lib/stream_data.ex | 4 ++-- test/stream_data_test.exs | 13 ++++--------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/stream_data.ex b/lib/stream_data.ex index a4bee21..40a7478 100644 --- a/lib/stream_data.ex +++ b/lib/stream_data.ex @@ -2254,11 +2254,11 @@ defmodule StreamData do ratio = Keyword.get(options, :ratio, 0.5) {numerator, denominator} = - if 0 < ratio and ratio < 1.0 do + if 0 <= ratio and ratio <= 1.0 do Float.ratio(ratio) else raise ArgumentError, - "expected :ratio to be greater than 0.0 and less than 1.0, got: #{inspect(ratio)}" + "expected :ratio to be greater than or equal to 0.0 and less than or equal to 1.0, got: #{inspect(ratio)}" end StreamData.frequency([{numerator, nil}, {denominator - numerator, generator}]) diff --git a/test/stream_data_test.exs b/test/stream_data_test.exs index bae9dcd..325086b 100644 --- a/test/stream_data_test.exs +++ b/test/stream_data_test.exs @@ -750,19 +750,14 @@ defmodule StreamDataTest do data = constant(:term) message = - "expected :ratio to be greater than 0.0 and less than 1.0, got:\n\n* ratio: 1.0\n" + "expected :ratio to be greater than or equal to 0.0 and less than or equal to 1.0, got: 1.1" - assert_raise ArgumentError, message, fn -> nullable(data, ratio: 1.0) end + assert_raise ArgumentError, message, fn -> nullable(data, ratio: 1.1) end message = - "expected :ratio to be greater than 0.0 and less than 1.0, got:\n\n* ratio: 0.0\n" + "expected :ratio to be greater than or equal to 0.0 and less than or equal to 1.0, got: -0.1" - assert_raise ArgumentError, message, fn -> nullable(data, ratio: 0.0) end - - message = - "expected :ratio to be greater than 0.0 and less than 1.0, got:\n\n* ratio: -0.0\n" - - assert_raise ArgumentError, message, fn -> nullable(data, ratio: -0.0) end + assert_raise ArgumentError, message, fn -> nullable(data, ratio: -0.1) end end property "returns nil or the value returned by data" do