Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Library for dealing with data structures

License

Notifications You must be signed in to change notification settings

ExpressApp/construct

Repository files navigation

Construct Hex.pm


Library for dealing with data structures



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

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

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

  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

About

Library for dealing with data structures

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

Languages

AltStyle によって変換されたページ (->オリジナル) /