Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Flint Feature Walkthrough

notebooks/feature_guide.livemd

Flint Feature Walkthrough

Mix.install(
  [
    {:flint, "~> 0.5"},
    {:poison, "~> 6.0"}
  ],
  consolidate_protocols: false
)

Introduction to Flint

Flint is a library that aims to make Ecto embedded_schema‘s more declarative, flexible, and expressive.

One of the core tenets of Flint is to be a drop-in replacement for Ecto, meaning that for all of your Ecto embedded schemas you can just switch from use Ecto.Schema to use Flint.Schema, and then you have to opt into all of the extra features of Flint.

Flint aims to empower you to colocate as much information as possible into your embedded_schema definitions, but Flint core is very unopinionated about how you use that data. Flint provides several build in extensions that are opinionated, but fundamentally Flint just exposes a way for you to store and retrieve additional data in an Ecto embedded_schema.

Core Features

The core features of Flint are those not packaged as Flint extensions.

All of these features are available even when specifying extensions: [] when using Flint.Schema

  • Added field!, embeds_one!, and embeds_many! macros that tag those fields as required and exposes them through the __schema__(:required) reflection function.
  • Added support for do block in field and field! to add validation_condition -> error_message pairs to the field.
  • Generated functions:
    • changeset/3
    • new/2
    • new!/2
  • Custom implementation of embedded_schema that uses the above macros from Flint.Schema instead of Ecto.Schema.
  • The ability to define aliases for field options
  • Application-wide default options using configurations

Let’s explore each of these in more detail

Using Generated Functions

changeset, new, and new!

Flint provides generated and overridable default implementations of changeset, new, and new! functions.

The new and new! functions use the changeset function.

The generated changeset function automatically accounts for the required fields, and now you can use changeset as you would any other changeset.

Many of the built-in extensions (which we will discuss in-depth later) build upon the core changeset function to build up a more comprehensive pipeline.

You can also use the generated new and new! functions. new will create a new struct from the passed params and will apply the changes regardless of validation, as opposed to new!, which will raise on validation errors, but otherwise will apply any valid changes.

Let’s take a look at a more practical example. In this example, we’re:

  • Using both normal and ! variants of field declarations
  • Using the shorthand notation where we pass the schema as an option to the use Flint call
  • Using both external and inline embeds fields.
  • Using an Ecto.Enum field type to map values between embedded and dumped representations.
defmodule Book do
  use Flint.Schema,
    schema: [
      field!(:title, :string),
      embeds_one! :author, Author_d do
        field!(:first_name)
        field!(:last_name)
        field(:bio, :string)
      end,
      embeds_many(:coauthors, Author_c),
      field(:genre, Ecto.Enum, values: [biography: 0, science_fiction: 1, fantasy: 2, mystery: 3])
    ]
end
{:module, Book, <<70, 79, 82, 49, 0, 0, 32, ...>>, :ok}

Now when we call Book.new it will create a new Book struct regardless of validation errors.

Note that by embeds_many(!) fields will default to an empty list ([]) at all times, whereas embeds_one! defaults to nil as it marks the field as :required, whereas embeds_one defaults to the empty struct (of its embedding). You can control this behavior for embeds_one using the defaults_to_struct boolean option.

Book.new()
%Book{title: nil, author: nil, coauthors: [], genre: nil}
Book.__schema__(:required)
[:title, :author]
Book.new!(%{title: "The old man and the sea"})

The generated changeset functions will also enforce :required validations for embedded fields, so if any required field of the :author embedded field is not present, then Book will fail validation.

Book.new!(%{
  title: "Harry Potter",
  author: %{first_name: "J.K."},
  genre: :fantasy
})
book =
  Book.new!(%{
    title: "Harry Potter",
    author: %{first_name: "J.K.", last_name: "Rowling"},
    genre: :fantasy
  })
%Book{
  title: "Harry Potter",
  author: %Book.Author_d{first_name: "J.K.", last_name: "Rowling", bio: nil},
  coauthors: [],
  genre: :fantasy
}
defmodule Book_b do
  use Flint.Schema,
    schema: [
      field!(:title, :string),
      embeds_one :author, Author_c do
        field!(:first_name)
        field!(:last_name)
        field(:bio, :string)
      end,
      embeds_many(:coauthors, Author_d),
      field(:genre, Ecto.Enum, values: [biography: 0, science_fiction: 1, fantasy: 2, mystery: 3])
    ]
end
{:module, Book_b, <<70, 79, 82, 49, 0, 0, 33, ...>>, :ok}

Note the quirk that if the embedded field is not marked as required, but one of its subfields is, then if the embedded struct is partially initialized it will fail if the required subfield is missing

Book_b.new!(%{
  title: "Harry Potter",
  author: %{first_name: "J.K."},
  genre: :fantasy
})

Whereas if it is not passed altogether then it will pass validation.

Book_b.new!(%{
  title: "Harry Potter",
  genre: :fantasy
})
%Book_b{
  title: "Harry Potter",
  author: %Book_b.Author_c{first_name: nil, last_name: nil, bio: nil},
  coauthors: [],
  genre: :fantasy
}

Required Fields

defmodule Person do
  use Flint.Schema, extensions: []

  @primary_key false
  embedded_schema do
    field! :first_name, :string
    field :last_name, :string

    embeds_many! :parents, Parent, primary_key: false do
      field! :relationship, Ecto.Enum, values: [:mother, :father]
      field! :first_name, :string
      field :last_name, :string
    end
  end
end
{:module, Person, <<70, 79, 82, 49, 0, 0, 21, ...>>, :ok}

Now we can use the generated function to create a new Person struct

mark_twain = %{
  first_name: "Mark",
  last_name: "Twain",
  parents: [
    %{
      first_name: "John",
      last_name: "Clemens",
      relationship: :father
    },
    %{
      first_name: "Jane",
      last_name: "Clemens",
      relationship: :mother
    }
  ]
}

Person.changeset(%Person{}, mark_twain)
#Ecto.Changeset<
  action: nil,
  changes: %{
    parents: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{first_name: "John", last_name: "Clemens", relationship: :father},
        errors: [],
        data: #Person.Parent<>,
        valid?: true,
        ...
      >,
      #Ecto.Changeset<
        action: :insert,
        changes: %{first_name: "Jane", last_name: "Clemens", relationship: :mother},
        errors: [],
        data: #Person.Parent<>,
        valid?: true,
        ...
      >
    ],
    first_name: "Mark",
    last_name: "Twain"
  },
  errors: [],
  data: #Person<>,
  valid?: true,
  ...
>

This generates an Ecto.Changeset. Let’s remove one of the fields that had been marked as required.

mark_twain_bad = Map.drop(mark_twain, [:first_name])
Person.changeset(%Person{}, mark_twain_bad)
#Ecto.Changeset<
  action: nil,
  changes: %{
    parents: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{first_name: "John", last_name: "Clemens", relationship: :father},
        errors: [],
        data: #Person.Parent<>,
        valid?: true,
        ...
      >,
      #Ecto.Changeset<
        action: :insert,
        changes: %{first_name: "Jane", last_name: "Clemens", relationship: :mother},
        errors: [],
        data: #Person.Parent<>,
        valid?: true,
        ...
      >
    ],
    last_name: "Twain"
  },
  errors: [first_name: {"can't be blank", [validation: :required]}],
  data: #Person<>,
  valid?: false,
  ...
>

This time, we get an error in our changeset, since we no longer provided a first_name.

You can see this difference in the new and new! generated functions as well, which will automatically apply the changes from the changeset to produce a struct for the provided schema.

Person.new(mark_twain)
%Person{
  first_name: "Mark",
  last_name: "Twain",
  parents: [
    %Person.Parent{id: nil, relationship: :father, first_name: "John", last_name: "Clemens"},
    %Person.Parent{id: nil, relationship: :mother, first_name: "Jane", last_name: "Clemens"}
  ]
}
Person.new(mark_twain_bad)
%Person{
  first_name: nil,
  last_name: "Twain",
  parents: [
    %Person.Parent{id: nil, relationship: :father, first_name: "John", last_name: "Clemens"},
    %Person.Parent{id: nil, relationship: :mother, first_name: "Jane", last_name: "Clemens"}
  ]
}

You can see that with new we apply the changes regardless of whether there are any errors present in the changeset.

If we want to raise if an error is present then we can use new! instead.

Person.new!(mark_twain)
%Person{
  first_name: "Mark",
  last_name: "Twain",
  parents: [
    %Person.Parent{id: nil, relationship: :father, first_name: "John", last_name: "Clemens"},
    %Person.Parent{id: nil, relationship: :mother, first_name: "Jane", last_name: "Clemens"}
  ]
}
Person.new!(mark_twain_bad)

Block Validations

Block validations can be specified using do blocks in field and field!. These are specified as lists of error_condition -> error_message pairs. If the error condition returns true, then the corresponding error_message will be inserted into the changeset when using the generated changeset, new, and new! functions.

Within these validations, you can pass custom bindings, meaning that you can define these validations with respect to variables only available at runtime.

In addition to any bindings you pass, the calues of the fields themselves will be available as a variable with the same name as the field.

You can also refer to local and imported / aliased function within these validations as well.

defmodule Person do
  use Flint.Schema, extensions: []

  def starts_with_capital?(""), do: false

  def starts_with_capital?(<>) do
    first in ?A..?Z
  end

  @primary_key false
  embedded_schema do
    field! :first_name, :string do
      !starts_with_capital?(first_name) -> "Must be capitalized!"
      String.length(first_name) >= 10 -> "Name too long!"
    end

    field(:last_name, :string)
  end
end
{:module, Person, <<70, 79, 82, 49, 0, 0, 23, ...>>, :ok}
Person.new!(%{first_name: "mark"})
Person.new!(%{first_name: "Mark"})
%Person{first_name: "Mark", last_name: nil}

All error conditions will be checked, so if multiple error conditions are met then you can be sure that they are reflected in the changeset.

Person.new!(%{first_name: "markmarkmark"})

Extensions

Flint is designed to be highly extensible and flexible. The main way to extend Flints functionality is through extensions.

Flint currently offers four ways to extend behavior:

  1. Schema-level attributes
  2. Field-level additional options
  3. Default embedded_schema definitions
  4. Injected Code

Note that extensions are inherited by every child embedded schema defined with embeds_one / embeds_many

1. Schema-Level Attributes

Extension let you define schema-level attributes which can then be reflected upon later. This is a pattern already used in Ecto such as with the @primary_key attribute, which can then be retrieved with __schema__(:primary_key). Flint simply lets you extend this to any module attribute you want.

However, you can still use this to modify attributes already used by Ecto. Let’s take a look at the built-in Embedded extension, which sets attributes to defaults which make more sense when using an embedded_schema rather than a schema, as an example.

defmodule Embedded do
  use Flint.Extension

  attribute :schema_prefix
  attribute :schema_context
  attribute :primary_key, default: false
  attribute :timestamp_opts, default: [type: :naive_datetime]
end
{:module, Embedded, <<70, 79, 82, 49, 0, 0, 47, ...>>,
 [
   Flint.Extension.Dsl.Attributes.Attribute.Options,
   nil,
   %{opts: [], entities: [%Flint.Extension.Field{...}, ...]}
 ]}

When you use the Embedded extension your schema will have these attributes set and can reflect on them.

defmodule ExampleEmbedded do
  use Flint.Schema, extensions: [Embedded]

  embedded_schema do
    field :name, :string
  end
end
{:module, ExampleEmbedded, <<70, 79, 82, 49, 0, 0, 20, ...>>, :ok}
ExampleEmbedded.__schema__(:fields)
[:name]

Notice that there is no :id field defined, which would be set if the @primary_key field were not set to false.

2. Field-Level Additional Options

This built-in JSON extension is inspired by how marshalling is done in Go, and registers three additional options that can be used to annotate a field. It then uses that option information to define implementations for Jason and Poison Encoder protocols, depending on which you specify.

You can see from that this uses the __schema__(:extra_options) reflection function on the schema, which stores all of the options registered across all extensions.

defmodule JSON do
  use Flint.Extension

  option :name, required: false, validator: &amp;is_binary/1
  option :omitempty, required: false, default: false, validator: &amp;is_boolean/1
  option :ignore, required: false, default: false, validator: &amp;is_boolean/1

  @doc false
  def encode_to_map(module, struct) do
    struct
    |> Ecto.embedded_dump(:json)
    |> Enum.reduce(%{}, fn {key, val}, acc ->
      field_opts = get_field_options(module, key)
      json_key = field_opts[:name] || to_string(key)

      cond do
        field_opts[:ignore] ->
          acc

        field_opts[:omitempty] &amp;&amp; is_nil(val) ->
          acc

        true ->
          Map.put(acc, json_key, val)
      end
    end)
  end

  defp get_field_options(module, field) do
    module.__schema__(:extra_options)
    |> Keyword.get(field, [])
    |> Enum.into(%{})
  end

  defmacro __using__(opts) do
    json_module = Keyword.get(opts, :json_module, Jason)
    protocol = Module.concat([json_module, Encoder])

    quote do
      if Code.ensure_loaded?(unquote(json_module)) do
        defimpl unquote(protocol) do
          def encode(value, opts) do
            encoded_map = Flint.Extensions.JSON.encode_to_map(unquote(__CALLER__.module), value)
            unquote(Module.concat([json_module, Encoder, Map])).encode(encoded_map, opts)
          end
        end
      end
    end
  end
end
{:module, JSON, <<70, 79, 82, 49, 0, 0, 60, ...>>, {:__using__, 1}}

Now let’s define a schema with specialized serialization using the JSON extension

defmodule ExampleJSON do
  use Flint.Schema, extensions: [JSON]

  @primary_key false
  embedded_schema do
    field! :first_name, :string, name: "First Name"
    field :last_name, :string, name: "Last Name", omitempty: true
    field :nicknames, {:array, :string}, ignore: true
  end
end
{:module, ExampleJSON, <<70, 79, 82, 49, 0, 0, 21, ...>>, :ok}
person =
  ExampleJSON.new!(%{
    first_name: "Charles",
    nicknames: ["Charlie"]
  })
%ExampleJSON{first_name: "Charles", last_name: nil, nicknames: ["Charlie"]}
Jason.encode!(person)
"{\"First Name\":\"Charles\"}"

Here, you can see the effects of each of the 3 options:

  1. The :first_name field is encoded as "First Name" as specified with the :name option
  2. The :last_name field is not encoded at all, since it was empty (nil)
  3. The :nicknames field is not encoded since it is ignored

You can also pass options to extensions. In this case, we can specify a different JSON library to use if we don’t want to use Jason, which is the default.

defmodule ExamplePoison do
  use Flint.Schema, extensions: [{JSON, json_module: Poison}]

  @primary_key false
  embedded_schema do
    field! :first_name, :string, name: "First Name"
    field :last_name, :string, name: "Last Name", omitempty: true
    field :nicknames, {:array, :string}, ignore: true
  end
end
{:module, ExamplePoison, <<70, 79, 82, 49, 0, 0, 21, ...>>, :ok}
person =
  ExamplePoison.new!(%{
    first_name: "Charles",
    nicknames: ["Charlie"]
  })
Poison.encode!(person)
"{\"First Name\":\"Charles\"}"

3. Default embedded_schema Definitions

Extensions also let you define default embedded_schema definitions which will be merged with any schema that uses it.

defmodule Inherited do
  use Flint.Extension

  attribute(:schema_prefix)
  attribute(:schema_context)
  attribute(:primary_key, default: false)
  attribute(:timestamp_opts, default: [type: :naive_datetime])

  embedded_schema do
    field!(:timestamp, :utc_datetime_usec)
    field!(:id)

    embeds_one :child, Child do
      field(:name, :string)
      field(:age, :integer)
    end
  end
end

defmodule Schema do
  use Flint.Schema, extensions: [Inherited]

  embedded_schema do
    field(:type, :string)
  end
end
{:module, Schema, <<70, 79, 82, 49, 0, 0, 23, ...>>, :ok}
Schema.__schema__(:fields)
[:type, :timestamp, :id, :child]
Schema.__schema__(:embeds)
[:child]
defmodule Person do
  use Flint.Schema

  embedded_schema do
    field :first_name
    field :last_name
  end
end
{:module, Person, <<70, 79, 82, 49, 0, 0, 29, ...>>, :ok}
defmodule Event do
  use Flint.Extension

  embedded_schema do
    field!(:timestamp, :utc_datetime_usec)
    field!(:id)
    embeds_one(:person, Person)

    embeds_one :child, Child do
      field(:name, :string)
      field(:age, :integer)
    end
  end
end
{:module, Event, <<70, 79, 82, 49, 0, 0, 47, ...>>, :ok}
defmodule Webhook do
  use Flint.Schema, extensions: [Event, Embedded]

  embedded_schema do
    field :route, :string
    field :name, :string
  end
end
{:module, Webhook, <<70, 79, 82, 49, 0, 0, 25, ...>>, :ok}
Webhook.__schema__(:extensions)
[Event, Flint.Extensions.Embedded]
Webhook.__schema__(:fields)
[:route, :name, :timestamp, :id, :person, :child]
Webhook.__schema__(:embeds)
[:person, :child]

4. Injected Code

Lastly, extensions can define their own __using__/1 macro that will be called by the schema using the extension.

This is also why the order in which extensions are specified matters, since extensions will be used by the calling schema module in the order that they are specified in the extensions option.

Here’s an example of the built-in Accessible extensions, which implements the Access behaviour for the schema.

defmodule Accessible do
  use Flint.Extension

  defmacro __using__(_opts) do
    quote do
      @behaviour Access

      @impl true
      defdelegate fetch(term, key), to: Map
      @impl true
      defdelegate get_and_update(term, key, fun), to: Map
      @impl true
      defdelegate pop(data, key), to: Map
    end
  end
end
{:module, Accessible, <<70, 79, 82, 49, 0, 0, 48, ...>>, {:__using__, 1}}
defmodule AccessibleSchema do
  use Flint.Schema, extensions: [Accessible]

  embedded_schema do
    field :name
  end
end
{:module, AccessibleSchema, <<70, 79, 82, 49, 0, 0, 22, ...>>, :ok}
person = AccessibleSchema.new!(%{name: "Mickey"})
person[:name]
"Mickey"

Custom Types

Ecto allows you to define custom types by implementing the Ecto.Type or Ecto.ParameterizedType behaviours.

Types are how you define the way in which data is imported (cast / load) and exported (dump) when using your schema. So when accepting external data, types are what determines how that data is put into and taken out of your struct, which is an important behavior to control when working ingesting external data or outputting your data to an external API.

This is common when using Ecto as a means to validate JSON data across programming language barriers.

You might find, however, as you try to write your own types that they can be quite tedious and verbose to implement.

That’s where Flint.Type comes in!

Flint.Type is meant to make writing new Ecto types require much less boilerplate, because you can base your type off of an existing type and only modify the callbacks that have different behavior.

Simply use Flint.Type and pass the :extends option which says which type module to inherit callbacks from. This will delegate all required callbacks and any implemented optional callbacks and make them overridable.

It also lets you make a type from an Ecto.ParameterizedType with default parameter values. You may supply any number of default parameters. This essentially provides a new init/1 implementation for the type, supplying the default values, while not affecting any of the other Ecto.ParameterizedType callbacks. You may still override the newly set defaults at the local level.

Just supply all options that you wish to be defaults as extra options when using Flint.Type.

You may override any of the inherited callbacks inherit from the extended module in the case that you wish to customize the module further.

defmodule Category do
  use Flint.Type, extends: Ecto.Enum, values: [:folder, :file]
end
{:module, Category, <<70, 79, 82, 49, 0, 0, 12, ...>>, :ok}

This will apply default values to Ecto.Enum when you supply a Category type to an Ecto schema. You may still override the values if you supply the :values option for the field.

defmodule Downloads do
  use Flint.Schema

  embedded_schema do
    field :type, Category
  end
end
{:module, Downloads, <<70, 79, 82, 49, 0, 0, 29, ...>>, :ok}
Downloads.new!(%{type: :folder})
%Downloads{type: :folder}
Downloads.new!(%{type: :another})

This will create a new NewUID type that behaves exactly like an Ecto.UUID except it dumps its string length.

import Flint.Type
deftype NewUID, extends: Ecto.UUID, dump: &amp;String.length/1

defmodule TestType do
  use Flint.Schema

  embedded_schema do
    field(:id, NewUID)
  end
end

Ecto.UUID.generate() |> NewUID.dump()
36

Default Extensions

You can get a list of the default extensions with Flint.default_extensions().

Flint.default_extensions()
[Flint.Extensions.PreTransforms, Flint.Extensions.When, Flint.Extensions.EctoValidations,
 Flint.Extensions.PostTransforms, Flint.Extensions.Accessible, Flint.Extensions.Embedded,
 Flint.Extensions.JSON]

You can optionally provide an :except option to filter which extensions to use.

Flint.default_extensions(except: [When])
[Flint.Extensions.PreTransforms, Flint.Extensions.EctoValidations, Flint.Extensions.PostTransforms,
 Flint.Extensions.Accessible, Flint.Extensions.Embedded, Flint.Extensions.JSON]

When explicitly passing which extensions to use, the default extensions are not automatically included, so you can use Flint.default_extensions to use them in addition to whatever extensions you explicitly use.

defmodule MySchema do
  use Flint.Schema, extensions: Flint.default_extensions(except: [JSON, When]) ++ [JSON]
end
{:module, MySchema, <<70, 79, 82, 49, 0, 0, 19, ...>>,
 {:module, Jason.Encoder.MySchema, <<70, 79, 82, ...>>, {:encode, 2}}}
MySchema.__schema__(:extensions)
[Flint.Extensions.PreTransforms, Flint.Extensions.EctoValidations, Flint.Extensions.PostTransforms,
 Flint.Extensions.Accessible, Flint.Extensions.Embedded, Flint.Extensions.JSON]

Built-In Extensions

Flint provides a bevy of built-in extensions (those listed in Flint.default_extensions) to provide common conveniences. When building out your own custom Flint extensions, you can refer to the implementation details for any of these extensions for reference.

Let’s walk through the different extensions:

Accessible

An extension to automatically implement the Access behaviour for your struct, deferring to the Map implementation.

defmodule AccessibleSchema do
  use Flint.Schema, extensions: [Accessible]

  embedded_schema do
    field :name
    embeds_one :embedded, AccessibleEmbed do
      field :type
      field :category
    end
  end
end
{:module, AccessibleSchema, <<70, 79, 82, 49, 0, 0, 24, ...>>, :ok}
a =
  AccessibleSchema.new!(%{
    name: "SampleName",
    embedded: %{type: "SampleType", category: "SampleCategory"}
  })
%AccessibleSchema{
  id: nil,
  name: "SampleName",
  embedded: %AccessibleSchema.AccessibleEmbed{
    id: nil,
    type: "SampleType",
    category: "SampleCategory"
  }
}
a[:name]
"SampleName"
a[:embedded][:category]
"SampleCategory"

EctoValidations

Shorthand options for common validations found in Ecto.Changeset

Just passthrough the option for the appropriate validation and this extension will take care of calling the corresponding function from Ecto.Changeset on your data.

Options

Aliases

By default, the following aliases are also available for convenience:

config Flint, aliases: [
  lt: :less_than,
  gt: :greater_than,
  le: :less_than_or_equal_to,
  ge: :greater_than_or_equal_to,
  eq: :equal_to,
  ne: :not_equal_to
]
defmodule EctoValidationsSchema do
  use Flint.Schema, extensions: [EctoValidations]

  embedded_schema do
    field! :first_name, :string,  max: 10, min: 5
    field! :last_name, :string, min: 5, max: 10
    field :favorite_colors, {:array, :string}, subset_of: ["red", "blue", "green"]
    field! :age, :integer, greater_than: 0, less_than: max_age
  end
end
{:module, EctoValidationsSchema, <<70, 79, 82, 49, 0, 0, 24, ...>>, :ok}
EctoValidationsSchema.changeset(
  %EctoValidationsSchema{},
  %{first_name: "Bob", last_name: "Smith", favorite_colors: ["red", "blue", "pink"], age: 101},
  max_age: 100
)
#Ecto.Changeset<
  action: nil,
  changes: %{
    first_name: "Bob",
    last_name: "Smith",
    favorite_colors: ["red", "blue", "pink"],
    age: 101
  },
  errors: [
    age: {"must be less than %{number}", [validation: :number, kind: :less_than, number: 100]},
    favorite_colors: {"has an invalid entry", [validation: :subset, enum: ["red", "blue", "green"]]},
    first_name: {"should be at least %{count} character(s)",
     [count: 5, validation: :length, kind: :min, type: :string]}
  ],
  data: #EctoValidationsSchema<>,
  valid?: false,
  ...
>

Embedded

An extension to house common default configurations for embedded schemas. These configurations are specific for in-memory schemas.

Attributes

The following attributes and defaults are set by this extension:

  • :schema_prefix
  • :schema_context
  • :primary_key - defaults to false
  • :timestamp_opts - defaults to [type: :naive_datetime]

A new schema reflection function is made for each attribute:

__schema__(:schema_context)
...
defmodule WithoutEmbedded do
  use Flint.Schema, extensions: []

  embedded_schema do
    field :name
  end
end
{:module, WithoutEmbedded, <<70, 79, 82, 49, 0, 0, 20, ...>>, :ok}
WithoutEmbedded.__schema__(:fields)
[:id, :name]
WithoutEmbedded.__schema__(:primary_key)
[:id]
defmodule WithEmbedded do
  use Flint.Schema, extensions: [Embedded]

  embedded_schema do
    field :name
  end
end
{:module, WithEmbedded, <<70, 79, 82, 49, 0, 0, 20, ...>>, :ok}
WithEmbedded.__schema__(:fields)
[:name]
WithEmbedded.__schema__(:primary_key)
[]

JSON

Provides JSON encoding capabilities for Flint schemas with Go-like marshalling options.

This extension enhances Flint schemas with customizable JSON serialization options, allowing fine-grained control over how fields are represented in JSON output.

Usage

To use this extension, include it in your Flint schema:

  defmodule MySchema do
    use Flint.Schema,
       extensions: [{JSON, json_module: :json}]  # Jason, or Poison
       #extensions: [JSON] # (defaults to Jason if no args passed)

    embedded_schema do
      # Schema fields...
    end
  end

JSON Encoding Options

The following options can be specified for each field in your schema:

  • :name - Specifies a custom name for the field in the JSON output.
  • :omitempty - When set to true, omits the field from JSON output if its value is nil.
  • :ignore - When set to true, always excludes the field from JSON output.

Defining Options

Options are defined directly in your schema using the field macro:

  embedded_schema do
    field :id, :string, name: "ID"
    field :title, :string, name: "Title", omitempty: true
    field :internal_data, :map, ignore: true
  end
defmodule Book do
  use Flint.Schema,
    extensions: [Embedded, JSON]

  embedded_schema do
    field(:id, :string, name: "ISBN")
    field(:title, :string)
    field(:author, :string, omitempty: true)
    field(:price, :decimal, name: "SalePrice")
    field(:internal_notes, :string, ignore: true)
  end
end
{:module, Book, <<70, 79, 82, 49, 0, 0, 23, ...>>, :ok}
book = %{
  id: "978-3-16-148410-0",
  title: "Example Book",
  author: nil,
  price: Decimal.new("29.99"),
  internal_notes: "Not for customer eyes"
}

book |> Book.new!() |> Jason.encode!()
"{\"ISBN\":\"978-3-16-148410-0\",\"SalePrice\":\"29.99\",\"title\":\"Example Book\"}"

You can even specify an alternate JSON module from Jason, such as Poison. In reality, this works with any JSON library that uses a protocol with an encoder implementation to dispatch its JSON encoding. Jason and Poison are the only officially supported ones, both having been tested with the current implementation.

You can specify the JSON library like so:

defmodule PoisonBook do
  use Flint.Schema,
    extensions: [Embedded, {JSON, json_module: Poison}]

  embedded_schema do
    field(:id, :string, name: "ISBN")
    field(:title, :string)
    field(:author, :string, omitempty: true)
    field(:price, :decimal, name: "SalePrice")
    field(:internal_notes, :string, ignore: true)
  end
end
{:module, PoisonBook, <<70, 79, 82, 49, 0, 0, 23, ...>>, :ok}
book |> PoisonBook.new!() |> Poison.encode!()
"{\"title\":\"Example Book\",\"SalePrice\":29.99,\"ISBN\":\"978-3-16-148410-0\"}"

PreTransforms

The PreTransforms provides a convenient :derive option to express how the field is computed.

By default, this occurs after casting and before validations.

derived fields let you define expressions with support for custom bindings to include any field declarations that occur before the current field.

:derive will automatically put the result of the input expression into the field value. By default, this occurs before any other validation, so you can still have access to field bindings and even the current computed field value (eg. within a :when validation from the When extension).

You can define a derived field with respect to the field itself, in which case it acts as transformation. Typically in Ecto, incoming transformations of this support would happen at the cast step, which means the behavior is determined by the type in which you are casting into. :derive lets you apply a transformation after casting to change that behavior without changing the underlying allowed type.

You can also define a derived field with an expression that does not depend on the field, in which case it is suggested that you use the field macro instead of field! since any input in that case would be thrashed by the derived value. This means that a field can be completely determined as a product of other fields!

defmodule Test do
  use Flint.Schema, extensions: [PreTransforms]

  embedded_schema do
    field!(:category, Union, oneof: [Ecto.Enum, :decimal, :integer], values: [a: 1, b: 2, c: 3])
    field!(:rating, :integer)
    field(:score, :integer, derive: rating + category)
  end
end
{:module, Test, <<70, 79, 82, 49, 0, 0, 24, ...>>, :ok}
Test.new!(%{category: 1, rating: 80})
%Test{id: nil, category: 1, rating: 80, score: 81}

PostTransforms

The PostTransforms extension adds the :map option to Flint schemas.

This works similarly to the PreTransforms extension, but uses the :map option rather than the :derive option used by PreTransforms, and by default, applies to the field after all validations.

The same caveats apply to the :map expression as all other expressions, with the exception that the :map function only accepts arity-1 anonymous functions or non-anonymous function expressions (eg. using variable replacement).

In the following example, :derived is used to normalize incoming strings to downcase to prepare for the validation, then the output is mapped to the uppercase string using the :map option.

defmodule Character do
  use Flint.Schema

  embedded_schema do
    field! :type, :string, derive: &amp;String.downcase/1, map: String.upcase(type) do
      type not in ~w[elf human] -> "Expected elf or human, got: #{type}"
    end

    field! :age, :integer do
      age < 0 ->
        "Nobody can have a negative age"

      type == "elf" and age > max_elf_age ->
        "Attention! The elf has become a bug! Should be dead already!"

      type == "human" and age > max_human_age ->
        "Expected human to have up to #{max_human_age}, got: #{age}"
    end
  end
end
{:module, Character, <<70, 79, 82, 49, 0, 0, 32, ...>>, :ok}
max_elf_age = 400
max_human_age = 120
Character.new!(%{type: "Elf", age: 10}, binding())

When

The When extension adds the :when option to Flint schemas.

:when lets you define an arbitrary boolean expression that will be evaluated and pass the validation if it evaluates to a truthy value. You may pass bindings to this condition and refer to previously defined fields. :when also lets you refer to the current field in which the :when condition is defined. Theoretically, you could write many of the other validations using :when, but you will receive worse error messages with :when than with the dedicated validations.

defmodule WhenTest do
  use Flint.Schema

  embedded_schema do
    field!(:category, Union, oneof: [Ecto.Enum, :decimal, :integer], values: [a: 1, b: 2, c: 3])
    field!(:rating, :integer, when: category == target_category)
    field!(:score, :integer, gt: 1, lt: 100, when: score > rating)
  end
end
{:module, WhenTest, <<70, 79, 82, 49, 0, 0, 31, ...>>, :ok}
WhenTest.new!(%{category: :a, rating: 80, score: 10}, target_category: :a)