Adzz

Adzz

Data_schema - declarative schemas for data transformations

Data schemas are declarative descriptions of how to create a struct from some input data. You can set up different schemas to handle different kinds of input data. By default we assume the incoming data is a map, but you can configure schemas to work with any arbitrary data input including XML and json.

Data is selected from the input data and passed to a casting function before being set as a value under a key on the struct you want to build.

Check out the docs / guides and README for more detailed information on how it works but below is a flavour of what you can do.

A simple struct

First, let’s assume that your input data is a map with string keys. DataSchemas really shine when working with APIs because we can quickly convert an API response into trusted elixir data:

input = %{
  "content" => "This is a blog post",
  "comments" => [%{"text" => "This is a comment"},%{"text" => "This is another comment"}],
  "draft" => %{"content" => "This is a draft blog post"},
  "date" => "2021-11-11",
  "time" => "14:00:00",
  "metadata" => %{ "rating" => 0}
}

Now let’s define a schema to create a BlogPost struct from the above input data:

defmodule BlogPost do
  import DataSchema, only: [data_schema: 1]

  data_schema([
    field: {:content, "content", &BlogPost.to_okay_string/1},
  ])
  
  def to_okay_string(value) do
     {:ok, to_string(value)}
  end
end

The above is equivalent to:

defmodule StringType do
  @behaviour DataSchema.CastBehaviour

  @impl true
  def cast(value) do
    {:ok, to_string(value)}
  end
end

defmodule BlogPost do
  import DataSchema, only: [data_schema: 1]

  data_schema([
    field: {:content, "content", StringType},
  ])
end

Now you have defined your schema you can simple call DataSchema.to_struct/2:

DataSchema.to_struct(input, BlogPost)
# => %BlogPost{content: "This is a blog post"}

A more complex example

You can define a few kinds of fields, see the docs for more info but here is a more complex example introducing more field types:

  defmodule DraftPost do
    import DataSchema, only: [data_schema: 1]
    data_schema(field: {:content, "content", StringType})
  end

  defmodule Comment do
    import DataSchema, only: [data_schema: 1]
    data_schema(field: {:text, "text", StringType})
  end

  defmodule BlogPost do
    import DataSchema, only: [data_schema: 1]

    @mapping [
      field: {:date, "date", &Date.from_iso8601/1},
      field: {:time, "time", &Time.from_iso8601/1}
    ]
    data_schema(
      field: {:content, "content", &DataSchemaTest.to_stringg/1},
      has_many: {:comments, "comments", Comment},
      has_one: {:draft, "draft", DraftPost},
      list_of: {:list_of, "comments", &{:ok, &1["text"]} },
      aggregate: {:post_datetime, @mapping, &BlogPost.to_datetime/1}
    )

    def to_datetime(%{date: date, time: time}) do
      NaiveDateTime.new(date, time)
    end
  end

DataSchema.to_struct(input, BlogPost)
# The above returns:
{:ok, %DataSchemaTest.BlogPost{
  list_of: ["This is a comment", "This is another comment"],
  comments: [
    %DataSchemaTest.Comment{text: "This is a comment"},
    %DataSchemaTest.Comment{text: "This is another comment"}
  ],
  content: "This is a blog post",
  draft: %DataSchemaTest.DraftPost{content: "This is a draft blog post"},
  post_datetime: ~N[2021-11-11 14:00:00]
}}

Different Input Data - aka Are these not just embedded_schemas from ecto?

The examples so far have shown functionality that is very similar to what you can get from Ecto’s embedded schemas and data casting capabilities. However, in DataSchema we can also provide different data accessors. This allows us to defines schemas that can be casted from different input data, for example…

XML Schemas

Let’s imagine that we have some XML that we wish to turn into a struct. What would it require to enable that? First a new Xpath data accessor:

defmodule XpathAccessor do
  @behaviour DataSchema.DataAccessBehaviour
  import SweetXml, only: [sigil_x: 2]

  @impl true
  def field(data, path) do
    SweetXml.xpath(data, ~x"#{path}"s)
  end

  @impl true
  def list_of(data, path) do
    SweetXml.xpath(data, ~x"#{path}"l)
  end

  @impl true
  def has_one(data, path) do
    SweetXml.xpath(data, ~x"#{path}")
  end

  @impl true
  def has_many(data, path) do
    SweetXml.xpath(data, ~x"#{path}"l)
  end
end

Let’s define our schemas like so:

defmodule DraftPost do
  import DataSchema, only: [data_schema: 1]

  @data_accessor XpathAccessor
  data_schema([
    field: {:content, "./Content/text()", StringType}
  ])
end

defmodule Comment do
  import DataSchema, only: [data_schema: 1]

  @data_accessor XpathAccessor
  data_schema([
    field: {:text, "./text()", StringType}
  ])
end

defmodule BlogPost do
  import DataSchema, only: [data_schema: 1]

  @data_accessor XpathAccessor
  @datetime_fields [
    field: {:date, "/Blog/@date", &Date.from_iso8601/1},
    field: {:time, "/Blog/@time", &Time.from_iso8601/1},
  ]
  data_schema([
    field: {:content, "/Blog/Content/text()", StringType},
    has_many: {:comments, "//Comment", Comment},
    has_one: {:draft, "/Blog/Draft", DraftPost},
    aggregate: {:post_datetime, @datetime_fields, &NaiveDateTime.new(&1.date, &1.time)},
  ])
end

And now we can transform as above:

source_data = """
<Blog date="2021-11-11" time="14:00:00">
  <Content>This is a blog post</Content>
  <Comments>
    <Comment>This is a comment</Comment>
    <Comment>This is another comment</Comment>
  </Comments>
  <Draft>
    <Content>This is a draft blog post</Content>
  </Draft>
</Blog>
"""

DataSchema.to_struct(source_data, BlogPost)

# This will output:

{:ok, %BlogPost{
   comments: [
     %Comment{text: "This is a comment"},
     %Comment{text: "This is another comment"}
   ],
   content: "This is a blog post",
   draft: %DraftPost{content: "This is a draft blog post"},
   post_datetime: ~N[2021-11-11 14:00:00]
 }}

Data Accessor - An Access example.

Let’s look back at our map version.

input = %{
  "content" => "This is a blog post",
  "comments" => [%{"text" => "This is a comment"},%{"text" => "This is another comment"}],
  "draft" => %{"content" => "This is a draft blog post"},
  "date" => "2021-11-11",
  "time" => "14:00:00",
  "metadata" => %{ "rating" => 0}
}

We could define a data accessor that looks like this:

defmodule AccessDataAccessor do
  @behaviour DataSchema.DataAccessBehaviour

  @impl true
  def field(data, path) do
    get_in(data, path)
  end

  @impl true
  def list_of(data, path) do
    get_in(data, path)
  end

  @impl true
  def has_one(data, path) do
    get_in(data, path)
  end

  @impl true
  def has_many(data, path) do
    get_in(data, path)
  end
end

Now we can define our schema:

defmodule Blog do
  import DataSchema, only: [data_schema: 1]

  @data_accessor AccessDataAccessor
  data_schema([
    list_of: {:comments, ["comments", Access.all(), "text"], &{:ok, to_string(&1)}},
  ])
end

And create a struct from this:

input = %{
  "content" => "This is a blog post",
  "comments" => [%{"text" => "This is a comment"},%{"text" => "This is another comment"}],
  "draft" => %{"content" => "This is a draft blog post"},
  "date" => "2021-11-11",
  "time" => "14:00:00",
  "metadata" => %{ "rating" => 0}
}
DataSchema.to_struct(input, Blog)
# Returns:
{:ok, %Blog{comments: ["This is a comment", "This is another comment"]}}

This is still an early version. There are some planned upcoming features before a v1 but it is certainly useable as is.

Most Liked

Adzz

Adzz

New Version Released!

Version 0.2.4:

Features

This release adds runtime schemas. Runtime schemas are schemas that are defined at runtime and allow for casting to existing structs or to a bare map instead of a struct. This makes it really easy to integrate with Ecto for example to save an XML response into a db.

See the livebook for more details: data_schema/livebooks/runtime_schemas.livemd at main · Adzz/data_schema · GitHub

Here is a small example of what is possible:

defmodule User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :age, :integer
  end

  def update_details_from_xml(user_id, xml) do
    schema = [
      field: {:name, "/Response/User/@name", &{:ok, &1}},
      field: {:age, "/Response/User/@age", &parse_in/1t}
    ]

    with {:ok, changes} <- DataSchema.to_struct(xml, %{}, schema, XpathAccessor),
      %User{} = user <- Repo.get(user_id, User),
      %{valid?: true} = changeset <- Ecto.Changeset.change(%User{}, changes) do
      Repo.update(changeset)
    end
  end

  defp parse_int(string) do 
    case Integer.parse(string)  do
      {int, _} -> {:ok, int}
      _error -> :error
    end
  end

end

xml = """
<Response>
  <User name="Jeff" age="12" />
</Response>
"""
User.update_details_from_xml("123", xml)
Adzz

Adzz

I’m also now realising I don’t think I ever actually linked to the repo:

https://github.com/Adzz/data_schema

Adzz

Adzz

Great questions!

Validations

Can I run validations on my data?

Right now the focus is on parsing over validation. What I mean by that is instead of doing something like this:

input = %{"name" => ""}

input
|> DataSchema.to_struct(User)
|> validate_name_not_blank()

Or even:

input = %{"name" => ""}

input
|> validate_name_not_blank()
|> DataSchema.to_struct(User)

we can define our casting function to return an :error if it receives an empty string:

defmodule NonBlankString do
  @behaviour DataSchema.CastBehaviour

  @impl true
  def cast(""), do: {:error, "Field was blank!"}
  def cast(value), do: {:ok, to_string(value)}
end

defmodule User do
  import DataSchema, only: [data_schema: 1]

  data_schema([
    field: {:user, "user", NonBlankString}
  ])
end

My current take on validations is that they are for when you can’t design away the need for them (via making illegal states unrepresentable). So the idea is that the schema defines what is valid.

HOWEVER - as you can see in the above examples you could define your own functions before / after struct creation if you felt the need.

It’s possible that some validations can’t be expressed per field, in which case we could add some in the future.

Phoenix Forms

There is nothing specially added yet for phoenix forms, but off the top of my head there are a few ways you could approach it. One way is to use a schemaless changeset in the form:

  def index(conn, _params) do
    types = %{name: :string}
    user = %User{}
    changeset = Ecto.Changeset.change({user, types}, %{})
    render(conn, "index.html", changeset: changeset)
  end

# With a form like this
<%= form_for @changeset, Routes.user_path(@conn, :create), fn f -> %>
  <label>
    Name: <%= text_input f, :name %>
  </label>
  <%= submit "Submit" %>
<% end %>

Then when you post the form:

def create(conn, %{"user" => user_input}) do
  case DataSchema.to_struct(user_input, User) do
     {:error, error} -> ...
     {:ok, struct} -> ...
  end
end

We could possibly make this easier by supplying a function something like DataSchema.schemaless_changeset_from_schema(User):

  def index(conn, _params) do
    changeset = DataSchema.schemaless_changeset_from_schema(User)
    render(conn, "index.html", changeset: changeset)
  end

You’d also have to do the work of converting the error to a changeset error, which we could probably write some functions to help with, but it might be as easy as:

def create(conn, %{"user" => user_input}) do
  case DataSchema.to_struct(user_input, User) do
     {:error, %{errors: [{field,  message}]}} ->
       changeset =
         {%User{}, %{name: :string}}
         |> Ecto.Changeset.change(user_input)
         |> Ecto.Changeset.add_error(field, message)
         
         render(conn, changeset: changeset)
     {:ok, struct} ->
       render(...)
  end
end

My feel is that ecto might feel more natural, but open to the use case.

Last Post!

Adzz

Adzz

New Version 0.5.0

This version provides much richer information when a casting function unexpectedly raises.

Often we write cast functions as modules that implement a cast function. This means if they raise the stacktrace unhelpfully points to that module and tells you nothing about which field in the schema blew up.

This release captures all unexpected raises in a cast function and re-raises a DataSchema.CastFunctionError with information about which field blew up.

This has proven very helpful for larger schemas.

The DataSchema.CastFunctionError wraps the exception that it catches meaning you can still pattern match on it if you wish to catch some exceptions yourself, for example:

try do
  DataSchema.to_struct(my_input, MySchema)
rescue
  %DataSchema.CastFunctionError{wrapped_error: %RuntimeError{}} ->
    Logger.error("Runtime Error!")
    ...
  error ->
    reraise error, __STACKTRACE__
end

Example error message:

     ** (DataSchema.CastFunctionError)

     Unexpected error when casting value "my_input_value"
     for field :comments in this part of the schema:

     list_of: {:comments, "comments", StringType},

     Full path to field was:

                Field  :comments in MySchema
     Under Field  :metadata in MyParentSchema

     The casting function raised the following error:

     ** (RuntimeError) An error occured!

Where Next?

Popular in Announcing Top

zorbash
I created Kitto a framework for dashboards inspired by Dashing. The distributed characteristics of Elixir and the low memory footprint...
New
mischov
import Meeseeks.CSS html = HTTPoison.get!("https://news.ycombinator.com/").body for story &lt;- Meeseeks.all(html, css("tr.athing")) do...
New
Azolo
Hey everyone, I just released WebSockex which is a Elixir WebSocket client. WebSockex strives to work as a OTP special process, be RFC6...
New
tmbb
PhoenixWS - Websockets over Phoenix Channels Source code on Github here: GitHub - tmbb/phoenix_ws: Websockets implemented over Phoenix Ch...
New
kevinlang
Hey all, We have made an Ecto3 Adapter for SQLite3, ecto_sqlite3! We have successfully on-boarded the full suite of integration tests (...
New
trisolaran
Hi! :waving_hand: I would like to present LiveSelect, a little library that I wrote to easily add a dynamic selection input to your LV f...
198 11220 107
New
type1fool
WebAuthnLiveComponent WebAuthnComponents See this post about renaming the package. Passwordless authentication for Phoenix LiveView app...
New

Other popular topics Top

KronicDeth
Elixir plugin for JetBrain’s IntelliJ Platform (including Rubymine) This is a plugin that adds support for Elixir to JetBrains IntelliJ...
289 36654 110
New
vonH
In asking this question I am more interested about the expressiveness of the language itself and less concerned about the availability of...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
sorentwo
Hello! tl;dr Announcing Oban, an Ecto based job processing library with a focus on reliability and historical observability. After spen...
985 44532 311
New
shijith.k
I am trying to start a new phoenix project with elixir 1.9, but mix phx.new does not work. It says that ** (Mix) The task "phx.new" could...
New
romenigld
I am trying to run a deploy with docker and I successfully runned with this command: docker build -t romenigld/blog-prod . but when I t...
New

We're in Beta

About us Mission Statement