View Source Construct Hex.pm


Library for dealing with data structures



installation

Installation

  1. Add construct to your list of dependencies in mix.exs:
def deps do
  [{:construct, "~> 2.0"}]
end
  1. Ensure construct is started before your application:
def application do
  [applications: [:construct]]
end

usage

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

Types

primitive-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

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

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 every make/2 and make!/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

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

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request