Skip to content

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:
defdepsdo[{:construct,"~> 2.0"}]end
  1. Ensure construct is started before your application:
defapplicationdo[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:

defmoduleUserdouseConstructdofield:namefield:age,:integerendend

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!

defmoduleAnswerdo@behaviourConstruct.Typedefcast("yes"),do: {:ok,true}defcast("no"),do: {:ok,false}defcast(_),do: {:error,:invalid_answer}end

And use it in your structure like this:

defmoduleQuizdouseConstructdofield:user_id,:integerfield:answers,{:array,Answer}endend
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:

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\"}}"

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:

defmoduleCustomTypedo@behaviourConstruct.Type@speccast(term)::{:ok,term}|{:error,term}|:errordefcast(value)do{:ok,value}endend

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

defmoduleUserdouseConstruct,struct_optsstructuredoincludemodule_namefieldnamefieldname,typefieldname,type,field_optsendend

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

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