Library for dealing with data structures
- Add
constructto your list of dependencies inmix.exs:
defdepsdo[{:construct,"~> 2.0"}]end- Ensure
constructis started before your application:
defapplicationdo[applications: [:construct]]endSuppose 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:
defmoduleUserdouseConstructdofield:namefield:age,:integerendendAnd 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!
defmoduleAnswerdo@behaviourConstruct.Typedefcast("yes"),do: {:ok,true}defcast("no"),do: {:ok,false}defcast(_),do: {:error,:invalid_answer}endAnd use it in your structure like this:
defmoduleQuizdouseConstructdofield:user_id,:integerfield:answers,{:array,Answer}endendiex>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:
defmoduleCommaListdo@behaviourConstruct.Typedefcast(""),do: {:ok,[]}defcast(v)whenis_binary(v),do: {:ok,String.split(v,",")}defcast(v)whenis_list(v),do: {:ok,v}defcast(_),do: :errorenddefmoduleSearchFilterRequestdouseConstructdofield:user_ids,[CommaList,{:array,:integer}],default: []endend(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:
defmoduleCommentdouseConstructdofield:textendenddefmodulePostdouseConstructdofield:titlefield:comments,{:array,Comment}endendiex>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:
defmodulePKdouseConstructdofield:primary_key,:integerendenddefmoduleTimestampsdouseConstructdofield:created_at,:utc_datetime,default: &DateTime.utc_now/0field:updated_at,:utc_datetime,default: nilendenddefmoduleUserdouseConstructdoincludePKincludeTimestampsfield:nameendendiex>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:
defmoduleUserdouseConstructdofield:namedofield:firstfield:last,:string,default: nilendendendiex>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:
defmoduleComplexDefaultsdouseConstructdofield:requiredfield:nesteddofield:key,:string,default: "nesting 1"field:nesteddofield:key,:string,default: "nesting 2"endendendendiex>%ComplexDefaults{}**(ArgumentError)thefollowingkeysmustalsobegivenwhenbuildingstructComplexDefaults: [:required]expandingstruct: ComplexDefaults.__struct__/1iex>%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:
defmoduleUserdouseConstructdofield:id,:integerfield:namefield:age,:integerendenddefmoduleBotdouseConstructdofield:id,:integerfield:namefield:versionendenddefmoduleAuthordo@behaviourConstruct.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!defcast(%{"age"=>_}=v),do: User.make(v)defcast(%{"version"=>_}=v),do: Bot.make(v)defcast(_),do: :errorenddefmodulePostdouseConstructdofield:author,Authorendendiex>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:
defmoduleServerdo@derive{Jason.Encoder,only: [:name,:operating_system]}useConstructdofield:namefield:passwordfield:operating_system,derive: Jason.Encoderdofield:name,:stringfield:arch,:string,default: "x86"endendendiex>{: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\"}}"t():- integer
- float
- boolean
- string
- binary
- decimal
- utc_datetime
- naive_datetime
- date
- time
- any
- array
- map
- struct
{:array, t()}{:map, t()}[t()]
You can use Ecto custom types like Ecto.UUID or implement by yourself:
defmoduleCustomTypedo@behaviourConstruct.Type@speccast(term)::{:ok,term}|{:error,term}|:errordefcast(value)do{:ok,value}endendNotice 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.
defmoduleUserdouseConstruct,struct_optsstructuredoincludemodule_namefieldnamefieldname,typefieldname,type,field_optsendendWhere:
use Construct, struct_optswhere:struct_opts— options passed to everymake/2andmake!/2calls as default options;
include module_namewhere:module_name— is struct module, that validates for existence in compile time;
field name, type, field_optswhere:name— atom;type— primitive or custom type, that validates for existence in compile time;field_opts.
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!/2iex>Post.make!(%{comments: %{},title: :test})**(Construct.MakeError)%{comments: {:invalid,%{}},title: {:invalid,:test}}iex:10: Post.make!/2iex>Post.make!(%{comments: [%{}],title: "what the title?"})**(Construct.MakeError)%{comments: %{text: {:missing,[nil]}}}iex:10: Post.make!/2- 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