construct
construct copied to clipboard
Library for dealing with data structures
Construct 
Library for dealing with data structures
- Installation
- Usage
- Types
- Construct definition
- Errors while making structures
Installation
- Add
construct
to your list of dependencies inmix.exs
:
def deps do
[{:construct, "~> 2.0"}]
end
- Ensure
construct
is started before your application:
def application do
[applications: [:construct]]
end
Usage
Suppose you have some user input from several sources (DB, HTTP request, WebSocket), and you will need to process that data into something type-validated, like User entity. With this library you can define a type-validated structure for this entity:
defmodule User do
use Construct do
field :name
field :age, :integer
end
end
And use it to cast your data into something identical, to prevent type coercion in different places of your code. Like this:
iex> User.make(%{"name" => "John Doe", "age" => "37"})
{:ok, %User{age: 37, name: "John Doe"}}
Pretty neat, yeah? But what if you need more complex type? We have a solution!
defmodule Answer do
@behaviour Construct.Type
def cast("yes"), do: {:ok, true}
def cast("no"), do: {:ok, false}
def cast(_), do: {:error, :invalid_answer}
end
And use it in your structure like this:
defmodule Quiz do
use Construct do
field :user_id, :integer
field :answers, {:array, Answer}
end
end
iex> Quiz.make(%{user_id: 42, answers: ["yes", "no", "no", "yes"]})
{:ok, %Quiz{answers: [true, false, false, true], user_id: 42}}
What if we need to parse 'optimized' query string from URL, like list of user ids separated by a comma? Do we need to create a custom type for each boxed type?
No! Just use type composition feature:
defmodule CommaList do
@behaviour Construct.Type
def cast(""), do: {:ok, []}
def cast(v) when is_binary(v), do: {:ok, String.split(v, ",")}
def cast(v) when is_list(v), do: {:ok, v}
def cast(_), do: :error
end
defmodule SearchFilterRequest do
use Construct do
field :user_ids, [CommaList, {:array, :integer}], default: []
end
end
(Use CommaList
type from construct_types package).
iex> SearchFilterRequest.make(%{"user_ids" => "1,2,42"})
{:ok, %SearchFilterRequest{user_ids: [1, 2, 42]}}
Also we have default
option in our user_ids
field:
iex> SearchFilterRequest.make(%{})
{:ok, %SearchFilterRequest{user_ids: []}}
What if I have a lot of identical code?
You can use already defined structures as types:
defmodule Comment do
use Construct do
field :text
end
end
defmodule Post do
use Construct do
field :title
field :comments, {:array, Comment}
end
end
iex> Post.make(%{title: "Some article", comments: [%{"text" => "cool!"}, %{text: "awesome!!!"}]})
{:ok, %Post{comments: [%Comment{text: "cool!"}, %Comment{text: "awesome!!!"}], title: "Some article"}}
And include repeated fields in structures:
defmodule PK do
use Construct do
field :primary_key, :integer
end
end
defmodule Timestamps do
use Construct do
field :created_at, :utc_datetime, default: &DateTime.utc_now/0
field :updated_at, :utc_datetime, default: nil
end
end
defmodule User do
use Construct do
include PK
include Timestamps
field :name
end
end
iex> User.make(%{name: "John Doe", primary_key: 42})
{:ok,
%User{created_at: #DateTime<2018-10-14 20:43:06.595119Z>, name: "John Doe",
primary_key: 42, updated_at: nil}}
iex> User.make(%{name: "John Doe", created_at: "2015-01-23 23:50:07", primary_key: 42})
{:ok,
%User{created_at: #DateTime<2015-01-23 23:50:07Z>, name: "John Doe",
primary_key: 42, updated_at: nil}}
What if I don't want to define module to make a nested field?
field
macro can do
it for you:
defmodule User do
use Construct do
field :name do
field :first
field :last, :string, default: nil
end
end
end
iex> User.make(name: %{first: "John"})
{:ok, %User{name: %User.Name{first: "John", last: nil}}}
Construct tries to fit in Elixir as much as it possible:
defmodule ComplexDefaults do
use Construct do
field :required
field :nested do
field :key, :string, default: "nesting 1"
field :nested do
field :key, :string, default: "nesting 2"
end
end
end
end
iex> %ComplexDefaults{}
** (ArgumentError) the following keys must also be given when building struct ComplexDefaults: [:required]
expanding struct: ComplexDefaults.__struct__/1
iex> %ComplexDefaults{required: 1}
%ComplexDefaults{
nested: %ComplexDefaults.Nested{
key: "nesting 1",
nested: %ComplexDefaults.Nested.Nested{key: "nesting 2"}
},
required: 1
}
What if I want to use union types?
Use custom types:
defmodule User do
use Construct do
field :id, :integer
field :name
field :age, :integer
end
end
defmodule Bot do
use Construct do
field :id, :integer
field :name
field :version
end
end
defmodule Author do
@behaviour Construct.Type
# here's the trick, just choose the type by yourself, based on keys or value in specific field.
# but be careful, because there can be atoms and strings in keys!
def cast(%{"age" => _} = v), do: User.make(v)
def cast(%{"version" => _} = v), do: Bot.make(v)
def cast(_), do: :error
end
defmodule Post do
use Construct do
field :author, Author
end
end
iex> Post.make(%{"author" => %{}})
{:error, %{author: :invalid}}
iex> Post.make(%{"author" => %{"age" => "420"}})
{:error, %{author: %{id: :missing, name: :missing}}}
iex> Post.make(%{"author" => %{"id" => "42", "name" => "john doe", "age" => "420"}})
{:ok, %Post{author: %User{age: 420, id: 42, name: "john doe"}}}
iex> Post.make(%{"author" => %{"id" => "42", "name" => "john doe", "version" => "1.0.0"}})
{:ok, %Post{author: %Bot{id: 42, name: "john doe", version: "1.0.0"}}}
How can I serialize my structures with Jason?
Use @derive
attribute and derive
option for nested fields:
defmodule Server do
@derive {Jason.Encoder, only: [:name, :operating_system]}
use Construct do
field :name
field :password
field :operating_system, derive: Jason.Encoder do
field :name, :string
field :arch, :string, default: "x86"
end
end
end
iex> {:ok, server} = Server.make(name: "example", password: "secret", operating_system: %{name: "MacOS"})
{:ok,
%Server{
name: "example",
operating_system: %Server.OperatingSystem{arch: "x86", name: "MacOS"},
password: "secret"
}}
iex> Jason.encode!(server)
"{\"name\":\"example\",\"operating_system\":{\"arch\":\"x86\",\"name\":\"MacOS\"}}"
Types
Primitive types
-
t()
:- integer
- float
- boolean
- string
- binary
- decimal
- utc_datetime
- naive_datetime
- date
- time
- any
- array
- map
- struct
-
{:array, t()}
-
{:map, t()}
-
[t()]
Complex (custom) types
You can use Ecto custom types like Ecto.UUID or implement by yourself:
defmodule CustomType do
@behaviour Construct.Type
@spec cast(term) :: {:ok, term} | {:error, term} | :error
def cast(value) do
{:ok, value}
end
end
Notice that cast/1
can return error with reason, this behaviour is supported only by Struct and you can't use types defined using Construct in Ecto schemas.
Construct definition
defmodule User do
use Construct, struct_opts
structure do
include module_name
field name
field name, type
field name, type, field_opts
end
end
Where:
-
use Construct, struct_opts
where:-
struct_opts
— options passed to everymake/2
andmake!/2
calls as default options;
-
-
include module_name
where:-
module_name
— is struct module, that validates for existence in compile time;
-
-
field name, type, field_opts
where:-
name
— atom; -
type
— primitive or custom type, that validates for existence in compile time; -
field_opts
.
-
Errors while making structures
When you provide invalid data to your structures you can get tuple with errors as maps:
iex> Post.make
{:error, %{comments: :missing, title: :missing}}
iex> Post.make(%{comments: %{}, title: :test})
{:error, %{comments: :invalid, title: :invalid}}
iex> Post.make(%{comments: [%{}], title: "what the title?"})
{:error, %{comments: %{text: :missing}}}
Or receive an exception with invalid data:
iex> Post.make!
** (Construct.MakeError) %{comments: {:missing, nil}, title: {:missing, nil}}
iex:10: Post.make!/2
iex> Post.make!(%{comments: %{}, title: :test})
** (Construct.MakeError) %{comments: {:invalid, %{}}, title: {:invalid, :test}}
iex:10: Post.make!/2
iex> Post.make!(%{comments: [%{}], title: "what the title?"})
** (Construct.MakeError) %{comments: %{text: {:missing, [nil]}}}
iex:10: Post.make!/2
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request