elixir-toniq / norm

Data specification and generation
MIT License
688 stars 29 forks source link

Feature request: specs for constant values #68

Open Qqwy opened 4 years ago

Qqwy commented 4 years ago

Currently, tuples and atoms already are converted to specs automatically (They implement the Norm.Conformer.Conformable protocol). I think it would make sense if that same protocol is implemented for other values that are often 'on their own':

For other datatypes, I'd like to see a wrapper function &constant/1 whose implementation is something like

def constant(val) do
  spec(&(&1 == val)) 
  |> with_gen(StreamData.constant(val))
end

One question that remains is what should happen if we want to mix constants with other specs.

For instance, if I have a web-application with a 'posts' route nested under users, we end up with something like:

def id_spec(), do: spec(is_string())

def posts_show_route(), do: spec(["users", id_spec(), "posts", id_spec()])
def posts_route(), do: spec(["users", id_spec(), "posts"])
def user_show_route(), do: spec(["users", id_spec()])
def users_route(), do: spec(["users"])

def routes(), do: one_of(posts_show_route(), posts_route(), users_show_route(), users_route()]

The problem in above example is passing a list immediately to spec(). One could write e.g. constant(["uses", id_spec()]) instead, but what would that mean?

What do you think?

keathley commented 4 years ago

I've been thinking about this a bit and discussing options with @wojtekmach. I've held off on implementing Conformable for the other "primitive" types simply because I wanted to be conservative. I didn't really know if that was a good pattern or not, so I held off knowing we could implement it later. I think it probably does make sense to support binaries, integers, and ranges. It would definitely clean up some of the specs in our services.

Constants are a bit trickier. Its possible to implement something like a constant now, but its verbose and not very elegant: spec(& &1 == "some value"). That's how we manage it today. But I think there is value in support constants as a first class entity.

Even with all of this, I don't think we have good support yet for your specific example. You could make it work, but again, its inelegant. The problem is that we don't have a good way to describe sequences of values. We can describe maps and generic collections. But we can't say, "this is a list where the first element should be 'users', the second element should be a UUID, and the third element should be 'posts'". I've been thinking for a bit that we need to support something like cat which would allow you to concatenate a list of specs as a sequence. I think that's really the main piece that's missing here; we have most other constructs you'd need.

So with all of these ideas together, we could re-write your example like so:

def id_spec(), do: spec(is_string())

def posts_show_route(), do: cat([users: "users", user_id: id_spec(), posts: "posts", post_id: id_spec()])
def posts_route(), do: cat([users: "users", user_id: id_spec(), posts: "posts"])
def user_show_route(), do: cat([users: "users", user_id: id_spec()])
def users_route(), do: cat([users: "user"])

def routes(), do: one_of([posts_show_route(), posts_route(), users_show_route(), users_route()])

There's a lot of individual work to do here. But what do you think about these ideas?

Qqwy commented 4 years ago

Thank you for your response!

Its possible to implement something like a constant now, but its verbose and not very elegant: spec(& &1 == "some value")

Yes. Besides being verbose/inelegant it will also not give you a usable property-checking generator. If you want that, the even more verbose

  spec(&(&1 == val)) 
  |> with_gen(StreamData.constant(val))

is currently required.

I think there is value in support constants as a first class entity.

:+1:


Even with all of this, I don't think we have good support yet for your specific example.

You are right. One idea would be to e.g. recognize the use of ++ inside the spec(...) macro to concatenate together two lists or two other list-returning specs. Something like:

users_route(), do: spec(["users", id_spec()])
posts_route(), do: spec(users_route() ++ ["posts", id_spec()])

:thinking: ... on top of introducing ++/2 this would also require lists themselves being treated similarly to tuples by Norm. I think it might be a nice syntax, but it obviously is only one possibility in a large design space.