SkillAgentSearch skills...

Flint

Declarative Ecto embedded schemas for data validation, coercion, and manipulation.

Install / Use

/learn @acalejos/Flint
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Flint

Flint version Hex Docs Hex Downloads Twitter Follow

<!-- BEGIN MODULEDOC -->

Declarative Ecto embedded_schemas for data validation, coercion, and manipulation.

Feature Overview

  • ! Variants of Ecto field, embeds_one, and embeds_many macros to mark a field as required (Required Fields)
  • Colocated input transformations let you either transform input fields before validation or derive field values from other fields (Derived Fields / Input Transformations)
  • Colocated validations, so you can define common validations alongside field declarations (Validations)
  • Colocated output transformations let you transform fields after validation (Mappings / Output Transformations)
  • Extensible using the Flint.Extension module. Default extensions include:
    • Accessible - Adds Access implementation to the target schemas
    • JSON - Adds a custom JSON encoding (Jason and Poison supported) implementation to the target schemas
    • Embedded - Sets good default module attribute values used by Ecto specifically tailored for in-memory embedded schemas
    • And more!
  • New Ecto.Schema Reflection Functions
    • __schema__(:required) - Returns list of fields marked as required (from ! macros)
    • And more!
  • Convenient generated function (changeset,new,new!,...) (Generated Functions)
  • Configurable Application-wide defaults for Ecto.Schema API (Config)
  • Conveniently create new Ecto types using the Flint.Type module and its deftype/2 macro (Flint.Type)

Installation

def deps do
  [
    {:flint, "~> 0.6"},
    # If you want access to the `Typed` extension to add generated typespecs
    {:typed_ecto_schema, "~> 0.4", runtime: false}
  ]
end

Motivation

Flint is built on top of Ecto and is meant to provide good defaults for using embedded_schemas for use outside of a database. It also adds a bevy of convenient features to the existing Ecto API to make writing schemas and validations much quicker.

Of course, since you're using Ecto, you can use this for use as an ORM, but this is emphasizing the use of embedded_schemas as just more expressive and powerful maps while keeping compatibility with Ecto.Changeset, Ecto.Type, and all of the other benefits Ecto has to offer.

In particular, Flint focuses on making it more ergonomic to use embedded_schemas as a superset of Maps, so a Flint.Schema by default implements the Access behaviour and implements the Jason.Encoder protocol.

Flint also was made to leverage the distinction Ecto makes between the embedded representation of the schema and the dumped representation. This means that you can dictate how you want the Elixir-side representation to look, and then provide transformations for how it should be dumped, which helps when you want the serialized representation to look different.

This is useful if you want to make changes in the server-side code without needing to change the client-side (or vice-versa). Or perhaps you want a mapped representation, where instead of an Ecto.Enum just converting its atom key to a string when dumped, it gets mapped to an integer, etc.

Basic Usage

If you want to declare a schema with Flint, just use Flint.Schema within your module, and now you have access to Flint's implementation of the embedded_schema/1 macro. You can declare an embedded_schema within your module as you otherwise would with Ecto. Within the embedded_schema/1 block, you also have access to Flints implementations of embeds_one,embeds_one!,embeds_many, embeds_many!, field, and field!.

defmodule User do
  use Flint.Schema

  embedded_schema do
    field! :username, :string
    field! :password, :string, redacted: true
    field :nickname, :string
  end
end

Flint Types

Flint also comes with some types that are automatically aliased when you use Flint.

Union

Union type for Ecto. Allows the field to be any of the specified types.

Flint.Type

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 inherity from the extended module in the case that you wish to customize the module further.

Examples

defmodule Category do
  use Flint.Type, extends: Ecto.Enum, values: [:folder, :file]
end

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.

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

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

Generated Functions

Flint provides default implementations for the following functions for any schema declaration. Each of these is overridable.

  • changeset - Creates a changeset by casting all fields and validating all that were marked as required. If a :default key is provided for a field, then any use of a bang (!) declaration will essentially be ignored since the cast will fall back to the default before any validations are performed.
  • new - Creates a new changeset from the empty module struct and applies the changes (regardless of whether the changeset was valid).
  • new! - Same as new, except raises if the changeset is not valid.

Flint Core

The core of Flint is the additional schema macros, which includes the bang (!) variants to mark a field as required, and the added support of validations through do blocks to fields, as well as the Flint.Extension API that allows extensions to define additional acceptable field options and module attributes that can be reflected upon.

All other functionality comes in the form of Flint extensions.

At their core, the new field and field! macros' only additional functionality over the default Ecto macros is to store the allowed Flint options into module attributes which are exposed as new reflection functions.

The bulk of the work done in Flint with validations and transformations of data occurs in the generated changeset function, which leaves it up to the end user whether to use the default implementation, roll their own from scratch using the information exposed through the reflection functions, or do something in between (such as tuning which extensions you use).

When you use Flint.Schema, you declare an overridable changeset function for your schema module that by default just delegates to the Flint.Changeset.changeset/3 function.

The Flint.Changeset.changeset/3 function operates as the following pipeline:

  1. Cast all fields (including embeds)
  2. Validate required fields (Required Fields)

Required Fields

Flint adds the convenience bang (!) macros (embed_one!,embed_many!, field!) for field declarations within your struct to declare a field as required within its changeset function.

Flint schemas also have a new reflection function in addition to the normal Ecto reflection functions.

  • __schema__(:required) -- Returns a list of all fields that were marked as required.

Field Validations

field do Blocks

In Flint, the field and field! macros now accept an optional do block to define condition/error pairs.

  embedded_schema do
    field! :type, :string 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
max_elf_age = 400
max_human_age = 120
Character.new!(%{type: "elf", age: 10}, binding())
** (ArgumentError) %Character{type: ["Expected elf or human, got: orc"], age: 10}
    (flint 0.0.1) lib/schema.ex:617: Flint.Schema.new!/3
    (elixir 1.15.7) src/elixir.erl:396: :elixir.eval_external_handler/3
    (stdlib 5.1.1) erl_eval.erl:750: :erl_eval.do_apply/7
    (elixir 1.15.7) src/elixir.erl:375: :elixir.eval_forms/4
    (elixir 1.15.7) lib/module/parallel_checker.ex:112: Module.ParallelChecker.verify/1
    lib/livebook/runtime/evaluator.ex:622: anonymous fn/3 in Livebook.Runtime.Evaluator.eval/4
    (elixir 1.15.7) lib/code.ex:574:

Related Skills

View on GitHub
GitHub Stars118
CategoryDevelopment
Updated5mo ago
Forks5

Languages

Elixir

Security Score

92/100

Audited on Oct 23, 2025

No findings