Repository: dashbitco/nimble_options
Pull request #70 (2021-09-10 09:53:00)

use a struct to wrap a valid schema and skip schema validation
See https://github.com/dashbitco/nimble_options/issues/69

This change makes possible to obtain a valid schema, and then validate options directly without validating the schema itself whenever options are to be validated.

I defined the struct directly in the NimbleOptions module, instead of creating a NimbleOptions.Schema module, because from there, I guess a couple options could be added to the struct, that would be passed into the validation, if someday `validate/2` becomes `validate/3` and support validation options.

(this is also why the flag is called `schema_valid?` and not just `valid?`)

Have a nice day :)

lud: One note though: The docs function do not work with the `recursive_schema()` fixture from the tests. I did not try to change to much things to make it work, as I guessed the recursive_schema was only to test documenting the `NimbleOptions@options_schema` itself.

Patch:
diff --git a/lib/nimble_options.ex b/lib/nimble_options.ex
index 9f0c7ed..a47ba33 100644
--- a/lib/nimble_options.ex
+++ b/lib/nimble_options.ex
@@ -188,10 +188,44 @@ defmodule NimbleOptions do
       ...> Exception.message(error)
       "expected :interval to be a positive integer, got: :oops! (in options [:producer, :rate_limiting])"
 
+  ## Validating Schemas
+
+  Each time `validate/2` is called, the given schema itself will be validated before validating
+  the options.
+
+  In most applications the schema will never change but validating options will be done
+  repeatedly.
+
+  To avoid the extra cost of validating the schema, it is possible to validate the schema once,
+  and then use that valid schema directly. This is done by using the `new!/1` function first, and
+  then passing the returned schema to `validate/2`.
+
+  ### Example
+
+      iex> raw_schema = [
+      ...>   hostname: [
+      ...>     required: true,
+      ...>     type: :string
+      ...>   ]
+      ...> ]
+      ...>
+      ...> schema = NimbleOptions.new!(raw_schema)
+      ...> NimbleOptions.validate([hostname: "elixir-lang.org"], schema)
+      {:ok, hostname: "elixir-lang.org"}
+
+  Calling `new!/1` from a function that receives options will still validate the schema each time
+  that function is called. Declaring the schema as a module attribute is supported:
+
+      @options_schema NimbleOptions.new!([...])
+
+  This schema will be validated at compile time. Calling `docs/1` on that schema is also
+  supported.
   """
 
   alias NimbleOptions.ValidationError
 
+  defstruct schema: []
+
   @basic_types [
     :any,
     :keyword_list,
@@ -214,6 +248,12 @@ defmodule NimbleOptions do
   """
   @type schema() :: keyword()
 
+  @typedoc """
+  The `NimbleOptions` struct embedding a validated schema. See the
+  Validating Schemas section in the module documentation.
+  """
+  @type t() :: %NimbleOptions{schema: schema()}
+
   @doc """
   Validate the given `options` with the given `schema`.
 
@@ -225,18 +265,15 @@ defmodule NimbleOptions do
   `NimbleOptions.ValidationError` struct explaining what's wrong with the options.
   You can use `raise/1` with that struct or `Exception.message/1` to turn it into a string.
   """
-  @spec validate(keyword(), schema()) ::
+  @spec validate(keyword(), schema() | t()) ::
           {:ok, validated_options :: keyword()} | {:error, ValidationError.t()}
-  def validate(options, schema) when is_list(options) and is_list(schema) do
-    case validate_options_with_schema(schema, options_schema()) do
-      {:ok, _validated_schema} ->
-        validate_options_with_schema(options, schema)
 
-      {:error, %ValidationError{} = error} ->
-        raise ArgumentError,
-              "invalid schema given to NimbleOptions.validate/2. " <>
-                "Reason: #{Exception.message(error)}"
-    end
+  def validate(options, %NimbleOptions{schema: schema}) do
+    validate_options_with_schema(options, schema)
+  end
+
+  def validate(options, schema) when is_list(options) and is_list(schema) do
+    validate(options, new!(schema))
   end
 
   @doc """
@@ -245,7 +282,7 @@ defmodule NimbleOptions do
   This function behaves exactly like `validate/2`, but returns the options directly
   if they're valid or raises a `NimbleOptions.ValidationError` exception otherwise.
   """
-  @spec validate!(keyword(), schema()) :: validated_options :: keyword()
+  @spec validate!(keyword(), schema() | t()) :: validated_options :: keyword()
   def validate!(options, schema) do
     case validate(options, schema) do
       {:ok, options} -> options
@@ -253,6 +290,24 @@ defmodule NimbleOptions do
     end
   end
 
+  @doc """
+  Validates the given `schema` and returns a wrapped schema to be used with `validate/2`.
+
+  If the given schema is not valid, raises a `NimbleOptions.ValidationError`.
+  """
+  @spec new!(schema()) :: t()
+  def new!(schema) when is_list(schema) do
+    case validate_options_with_schema(schema, options_schema()) do
+      {:ok, validated_schema} ->
+        %NimbleOptions{schema: validated_schema}
+
+      {:error, %ValidationError{} = error} ->
+        raise ArgumentError,
+              "invalid schema given to NimbleOptions.validate/2. " <>
+                "Reason: #{Exception.message(error)}"
+    end
+  end
+
   @doc ~S"""
   Returns documentation for the given schema.
 
@@ -289,8 +344,14 @@ defmodule NimbleOptions do
           ]
 
   """
-  @spec docs(schema(), keyword()) :: String.t()
-  def docs(schema, options \\ []) when is_list(schema) and is_list(options) do
+  @spec docs(schema(), keyword() | t()) :: String.t()
+  def docs(schema, options \\ [])
+
+  def docs(schema, options) when is_list(schema) and is_list(options) do
+    NimbleOptions.Docs.generate(schema, options)
+  end
+
+  def docs(%NimbleOptions{schema: schema}, options) when is_list(options) do
     NimbleOptions.Docs.generate(schema, options)
   end
 
diff --git a/lib/nimble_options/docs.ex b/lib/nimble_options/docs.ex
index 063bc24..e8bc244 100644
--- a/lib/nimble_options/docs.ex
+++ b/lib/nimble_options/docs.ex
@@ -71,7 +71,7 @@ defmodule NimbleOptions.Docs do
   end
 
   defp get_required_str(schema) do
-    schema[:required] && "Required."
+    if schema[:required], do: "Required."
   end
 
   defp get_default_str(schema) do


Test patch:
diff --git a/test/nimble_options_test.exs b/test/nimble_options_test.exs
index 0540d43..49d7b6a 100644
--- a/test/nimble_options_test.exs
+++ b/test/nimble_options_test.exs
@@ -1356,6 +1356,7 @@ defmodule NimbleOptionsTest do
       """
 
       assert NimbleOptions.docs(schema) == docs
+      assert NimbleOptions.docs(NimbleOptions.new!(schema)) == docs
     end
 
     test "passing specific indentation" do
@@ -1541,6 +1542,32 @@ defmodule NimbleOptionsTest do
     end
   end
 
+  @compile_time_wrapper NimbleOptions.new!(an_option: [])
+
+  describe "wrapper struct" do
+    test "can be built from a valid schema" do
+      valid_schema = [an_option: [], other_option: []]
+      assert %NimbleOptions{} = NimbleOptions.new!(valid_schema)
+
+      invalid_schema = [:atom]
+
+      assert_raise FunctionClauseError, fn ->
+        NimbleOptions.new!(invalid_schema)
+      end
+    end
+
+    test "will not be validated once built" do
+      invalid_schema = [{"a_binary_key", []}]
+      invalid_struct = %NimbleOptions{schema: invalid_schema}
+
+      assert catch_error(NimbleOptions.validate([], invalid_struct))
+    end
+
+    test "can be built at compile time" do
+      assert {:ok, _} = NimbleOptions.validate([an_option: 1], @compile_time_wrapper)
+    end
+  end
+
   def buffer_keep(value) when value in [:first, :last] do
     {:ok, value}
   end