Repository: dashbitco/nimble_options
Pull request #55 (2020-11-02 08:31:09)

Support nested schemas in the :or type
@josevalim I completely overlooked nested schemas in #54. This to me seems like the best (= least horrible, still pretty ugly) way of dealing with them.

```elixir
schema = [
  docs: [
    type: {:or, [:boolean, {:keyword_list, enabled: [type: :boolean]}]}
  ]
]
```

Thoughts?

josevalim: This should also work:

```elixir
schema = [
  docs: [
    type: {:or, [:boolean, keyword_list: [enabled: [type: :boolean]]]}
  ]
]

Is it better? Thoughts?
whatyouhide: @josevalim that's the same data structure, yeah, it works in the same way. Do you mean I should update the examples?

Patch:
diff --git a/lib/nimble_options.ex b/lib/nimble_options.ex
index 42aa567..690396e 100644
--- a/lib/nimble_options.ex
+++ b/lib/nimble_options.ex
@@ -113,7 +113,12 @@ defmodule NimbleOptions do
     * `{:or, subtypes}` - A value that matches one of the given `subtypes`. The value is
       matched against the subtypes in the order specified in the list of `subtypes`. If
       one of the subtypes matches and **updates** (casts) the given value, the updated
-      value is used. For example: `{:or, [:string, :boolean, {:fun, 2}]}`.
+      value is used. For example: `{:or, [:string, :boolean, {:fun, 2}]}`. If one of the
+      subtypes is a keyword list, you won't be able to pass `:keys` directly. For this reason,
+      keyword lists (`:keyword_list` and `:non_empty_keyword_list`) are special cased and can
+      be used as subtypes with `{:keyword_list, keys}` or `{:non_empty_keyword_list, keys}`.
+      For example, a type such as `{:or, [:boolean, keyword_list: [enabled: [type: :boolean]]]}`
+      would match either a boolean or a keyword list with the `:enabled` boolean option in it.
 
   ## Example
 
@@ -493,10 +498,30 @@ defmodule NimbleOptions do
   defp validate_type({:or, subtypes}, key, value) do
     result =
       Enum.reduce_while(subtypes, _errors = [], fn subtype, errors_acc ->
+        {subtype, nested_schema} =
+          case subtype do
+            {keyword_list, keys} when keyword_list in [:keyword_list, :non_empty_keyword_list] ->
+              {keyword_list, keys}
+
+            other ->
+              {other, _nested_schema = nil}
+          end
+
         case validate_type(subtype, key, value) do
-          :ok -> {:halt, {:ok, value}}
-          {:ok, value} -> {:halt, {:ok, value}}
-          {:error, %ValidationError{} = reason} -> {:cont, [reason | errors_acc]}
+          :ok when not is_nil(nested_schema) ->
+            case validate_options_with_schema_and_path(value, nested_schema, _path = [key]) do
+              {:ok, value} -> {:halt, {:ok, value}}
+              {:error, %ValidationError{} = error} -> {:cont, [error | errors_acc]}
+            end
+
+          :ok ->
+            {:halt, {:ok, value}}
+
+          {:ok, value} ->
+            {:halt, {:ok, value}}
+
+          {:error, %ValidationError{} = reason} ->
+            {:cont, [reason | errors_acc]}
         end
       end)
 
@@ -572,11 +597,16 @@ defmodule NimbleOptions do
   end
 
   def type({:or, subtypes} = value) when is_list(subtypes) do
-    Enum.reduce_while(subtypes, {:ok, value}, fn subtype, acc ->
-      case type(subtype) do
-        {:ok, _value} -> {:cont, acc}
-        {:error, reason} -> {:halt, {:error, "invalid type in :or for reason: #{reason}"}}
-      end
+    Enum.reduce_while(subtypes, {:ok, value}, fn
+      {keyword_list_type, _keys}, acc
+      when keyword_list_type in [:keyword_list, :non_empty_keyword_list] ->
+        {:cont, acc}
+
+      subtype, acc ->
+        case type(subtype) do
+          {:ok, _value} -> {:cont, acc}
+          {:error, reason} -> {:halt, {:error, "invalid type in :or for reason: #{reason}"}}
+        end
     end)
   end
 


Test patch:
diff --git a/test/nimble_options_test.exs b/test/nimble_options_test.exs
index ae40edb..9a7f822 100644
--- a/test/nimble_options_test.exs
+++ b/test/nimble_options_test.exs
@@ -602,6 +602,20 @@ defmodule NimbleOptionsTest do
       assert NimbleOptions.validate(opts, schema) == {:ok, opts}
     end
 
+    test "valid {:or, subtypes} with nested keyword lists" do
+      schema = [
+        docs: [
+          type: {:or, [:boolean, keyword_list: [enabled: [type: :boolean]]]}
+        ]
+      ]
+
+      opts = [docs: false]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+
+      opts = [docs: [enabled: true]]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+    end
+
     test "invalid {:or, subtypes}" do
       schema = [docs: [type: {:or, [:string, :boolean]}]]
 
@@ -652,6 +666,53 @@ defmodule NimbleOptionsTest do
                {:error, %ValidationError{key: :docs, value: 1, message: expected_message}}
     end
 
+    test "invalid {:or, subtypes} with nested keyword lists" do
+      schema = [
+        docs: [
+          type: {:or, [:boolean, keyword_list: [enabled: [type: :boolean]]]}
+        ]
+      ]
+
+      opts = [docs: "123"]
+
+      expected_message = """
+      expected :docs to match at least one given type, but didn't match any. \
+      Here are the reasons why it didn't match each of the allowed types:
+
+        * expected :docs to be a keyword list, got: "123"
+        * expected :docs to be a boolean, got: "123"\
+      """
+
+      assert NimbleOptions.validate(opts, schema) ==
+               {:error,
+                %ValidationError{
+                  key: :docs,
+                  value: "123",
+                  keys_path: [],
+                  message: expected_message
+                }}
+
+      opts = [docs: [enabled: "not a boolean"]]
+
+      expected_message = """
+      expected :docs to match at least one given type, but didn't match any. \
+      Here are the reasons why it didn't match each of the allowed types:
+
+        * expected :enabled to be a boolean, got: "not a boolean" (in options [:docs])
+        * expected :docs to be a boolean, got: [enabled: "not a boolean"]\
+      """
+
+      assert NimbleOptions.validate(opts, schema) == {
+               :error,
+               %NimbleOptions.ValidationError{
+                 key: :docs,
+                 value: [enabled: "not a boolean"],
+                 keys_path: [],
+                 message: expected_message
+               }
+             }
+    end
+
     test "{:custom, mod, fun, args} with empty args" do
       schema = [buffer_keep: [type: {:custom, __MODULE__, :buffer_keep, []}]]