norm icon indicating copy to clipboard operation
norm copied to clipboard

Composing schemas

Open edkelly303 opened this issue 6 years ago • 3 comments

Thank you so much for this library!

I'm not sure if I just missed this, or if it's a bad idea, or if there's a better way to achieve it - but does Norm include an "and" for specs/schemas, corresponding to the "or" provided by one_of?

For example, say I have two schemas like:

quick_checks = schema(%{
  id: spec(is_integer()), 
  name: spec(is_string())
})

expensive_checks = schema(%{
  id: spec(some_complex_calculation()),
  age: spec(another_complex_calculation())
})

Perhaps most of the time I only want to do the quick_checks, but on some occasions I want to do ALL the checks. Is there (/should there be) a way to compose the two schemas so that I end up with something like the following?

all_checks = schema(%{
  id: spec(is_integer() and some_complex_calculation()), 
  name: spec(is_string()), 
  age: spec(another_complex_calculation())
})

I guess if you're using conform!/2 it doesn't really matter, because you can just do

data 
|> conform!(quick_checks) 
|> conform!(expensive_checks)

and you'll get an exception if something goes wrong.

But with conform/2 it's a bit trickier if you want to catch all the validation errors in one output. I wrote a helper function called conform_all, but it doesn't seem like a very elegant approach.

def conform_all(input, specs_and_schemas) do
  Enum.reduce(specs_and_schemas, {:ok, input}, fn spec_or_schema, previous_result ->
    case previous_result do
      {:ok, input} ->
        case conform(input, spec_or_schema) do
          {:ok, input} -> {:ok, input}
          {:error, new_errors} -> {:error, new_errors}
        end

      {:error, existing_errors} ->
        case conform(input, spec_or_schema) do
          {:ok, _input} -> {:error, existing_errors}
          {:error, new_errors} -> {:error, existing_errors ++ new_errors}
        end
    end
  end)
end

Apologies if I'm missed something obvious, and thanks so much again for Norm!

edkelly303 avatar Jan 23 '20 12:01 edkelly303

There are a couple of ways around this in Norm's current form. The most straightforward is to compose the maps before passing them to schema. Something like:

quick_checks = %{
  id: spec(is_integer()), 
  name: spec(is_string())
}

expensive_checks = %{
  id: spec(some_complex_calculation()),
  age: spec(another_complex_calculation())
}

checks = schema(Map.merge(quick_checks, expensive_checks))

This obviously only works for schemas and is still pretty limited. We're working on a more general way to compose arbitrary specs. This might be something like an all_of function.

keathley avatar Feb 01 '20 20:02 keathley

Thanks Chris, I hadn't thought of Map.merge - that's helpful.

If you do go down this road, I think all_of seems like exactly the right name for such a function - that's actually what I looked for in the docs, once I had seen one_of and coll_of.

Thanks again!

edkelly303 avatar Feb 03 '20 18:02 edkelly303

Speaking of all_of and one_of, perhaps worth deprecating one_of in favour of any_of? The rationale is twofold:

  • it would match Enum.any?/2 and Enum.all?/2
  • "one of" could be interpreted as "exactly one of", this is subtlety different than "any of". To be fair whenever I used one_of, the predicates were mutually exclusive, but just mentioning this anyway.

wojtekmach avatar Feb 04 '20 19:02 wojtekmach