TypedEctoSchema provides a DSL on top of Ecto.Schema to define schemas with typespecs without
all the boilerplate code.
Rationale
Normally, when defining an Ecto.Schema you probably want to define:
- the schema itself
- the list of enforced keys (which helps reducing problems)
- its associated type (
Ecto.Schemadoesn't define it for you)
It ends up in something like this:
defmodule Person do
use Ecto.Schema
@enforce_keys [:name]
schema "people" do
field(:name, :string)
field(:age, :integer)
field(:happy, :boolean, default: true)
field(:phone, :string)
belongs_to(:company, Company)
timestamps(type: :naive_datetime_usec)
end
@type t() :: %__MODULE__{
__meta__: Ecto.Schema.Metadata.t(),
id: integer() | nil,
name: String.t(),
age: non_neg_integer() | nil,
happy: boolean(),
phone: String.t() | nil,
company_id: integer() | nil,
company: Company.t() | Ecto.Association.NotLoaded.t() | nil,
inserted_at: NaiveDateTime.t() | nil,
updated_at: NaiveDateTime.t() | nil
}
endThis is problematic for a lot of reasons, summing up:
- A lot of repetition. Field names appear in 3 different places, so in order to understand one field, a reader needs to go up and down the code to get that.
- Ecto has some "hidden" fields that are added behind the scenes to the struct, such as the
primary key
id, the foreign keycompany_id, the timestamps and the__meta__field for schemas. Knowing all those rules can be hard to remember and would probably be easily forgotten when changing the schema. Also, Ecto has strange types for associations and metadata that need to be remembered.
All of this makes this process extremely repetitive and error prone. Sometimes, you want to
enforce factory functions to control defaults in a better way, you would probably add all fields
to @enforce_keys. This would make the @enforce_keys big and repetitive, once again.
This module aims to help with that, by providing some syntax sugar that allow you to define this in a more compact way.
defmodule Person do
use TypedEctoSchema
typed_schema "people" do
field(:name, :string, enforce: true, null: false)
field(:age, :integer) :: non_neg_integer() | nil
field(:happy, :boolean, default: true, null: false)
field(:phone, :string)
belongs_to(:company, Company)
timestamps(type: :naive_datetime_usec)
end
endThis is way simpler and less error prone. There is a lot going under the hoods here.
Field Options
All ecto macros are called under the hood with the options you pass, with exception of a few added options:
:null- whentrue, adds a| nilto the typespec. Default istrue. Has no effect onhas_one/3because it can always benil. Onbelongs_to/3only add| nilto the underlying foreign key.:enforce- whentrueadds the field to the@enforce_keys. Default isfalse:doc- a documentation string for the field, rendered into the@moduledocwhen it contains the fields marker (see the "Documenting Fields" section below). It is always stripped before the underlying Ecto macro runs.
Schema Options
When calling typed_schema/3 or typed_embedded_schema/2 you can pass some options, as
defined:
:null- Set the default:nullfield option, which normally is true. Note that it is still can be overwritten by passing:nullto the field itself. Also,embeds_manyandhas_manycan never be null, because they are always initialized to an empty list, so they never receive the| nilon the typespec. In addition to that,has_one/3andbelongs_to/3always receive| nilbecause the related schema may be deleted from the repo so it is safe to always assume they can benil.:enforce- Whentrue, enforces all fields unless they explicitly setenforce: falseor defines a default (default: value), since it makes no sense to have a default value for an enforced field.:opaque- Whentruemakes the generated typetbe an opaque type.:additional_types- (Experimental) Whentrue, defines a public named type for eachEcto.Enumand polymorphic embed field, which can be referenced from other modules' specs. Default isfalse, or the value of the:additional_typesapplication config when set. See the Experimental Features guide.
Type Inference
TypedEctoSchema does its best job to guess the typespec for the field. It does so by following
the Elixir types as defined in Ecto.Schema.
For custom Ecto.Type and related schemas (embedded and associations), which are always a
module, it assumes the schema has a type t/0 defined, so for a schema called MySchema, it
will assume the type is MySchema.t/0, which is also the default type generated by this
library.
Overriding the Typespec
If for some reason you want to narrow the type or the automatic type inference is incorrect,
the :: operator allows the typespec to be overriden.
This is done as you would when defining typespecs.
So, for example, instead of
field(:my_int, :integer)Which would generate a integer() | nil typespec, you can:
field(:my_int, :integer) :: non_neg_integer() | nilAnd then have a non_neg_integer() type for it.
Generated Fields
Ecto generates some fields for you in a lot of cases, they are:
- For primary keys
- When using a
belongs_to/3 - When calling
timestamps/1
The __meta__ typespec is automatically generated and cannot be overriden. That is because
there is no point on overriding it.
Primary Keys
Primary keys are generated by default and can be customized by the @primary_key module
attribute, just as defined by Ecto. We handle @primary_key the same way we handle field/3, so you
can pass the same field options to it, including the extra :null and :enforce options
(they are stripped before Ecto sees them):
@primary_key {:id, :binary_id, autogenerate: true, null: false}However, if you want to customize the type, you need to set @primary_key false and define a
field with primary_key: true.
Belongs To
belongs_to generates an underlying foreign key that is dependent on a few Ecto options, as
defined on Ecto.Schema.
The options we are interested in are :foreign_key, :define_field and :type
When :null is passed, it will add | nil to the generated foreign_key's typespec.
The :enforce option enforces the association field instead.
If you want to :enforce the foreign key to be set, you should probably pass define_field: false and define the foreign key by hand, setting another field/3, the same way as
described by Ecto's doc.
Timestamps
In the case of the timestamps, we currently don't allow overriding the type by using the :: operator.
That being said, however, we define the type of the fields using the :type option
(as defined by Ecto doc)
The timestamp fields are nullable by default, since a struct that was not inserted yet has nil
timestamps. You can pass the :null and :enforce options to override that:
timestamps(null: false)Documenting Fields
Fields accept a :doc option with a documentation string. To render the collected docs,
put the <!-- typed_ecto_schema: fields --> marker anywhere in the module's @moduledoc
and it is replaced at compile time with a markdown list describing every field:
defmodule Person do
@moduledoc """
A person.
## Fields
<!-- typed_ecto_schema: fields -->
"""
use TypedEctoSchema
typed_schema "people" do
field(:name, :string, null: false, doc: "The person's full name")
field(:age, :integer)
end
endThis generates documentation equivalent to:
@moduledoc """
A person.
## Fields
- `id` (`integer() | nil`)
- `name`: The person's full name (`String.t()`)
- `age` (`integer() | nil`)
"""Some details:
- The marker is the only trigger: without it (or without a
@moduledoc), the:docoptions are simply ignored. Since the marker is an HTML comment, it is invisible in rendered documentation even when left unreplaced. - The marker is replaced by the list alone, without any heading, so the surrounding structure (headings, placement) is entirely yours.
- The list includes all fields with their typespecs, whether they have a
:docor not, including the generated ones (the primary key,belongs_toforeign keys and timestamps). The internal__meta__field is skipped. - The
@moduledocmust be defined before thetyped_schemacall (its conventional position at the top of the module). - The
:docoption is accepted everywhere:nulland:enforceare:field/3, associations, embeds, polymorphic embeds and the@primary_keyattribute.
The generated @typedoc
Independently of the marker, the generated t/0 type gets a @typedoc containing a
"Fields" heading and the same list — for the example above, equivalent to:
@typedoc """
## Fields
- `id` (`integer() | nil`)
- `name`: The person's full name (`String.t()`)
- `age` (`integer() | nil`)
"""This happens for every schema, whether or not any field has a :doc, so field docs are
never lost: they show up in t Person.t() in IEx, on hover in editors, and on the type
itself in the generated documentation. It only happens when the module defines no
@typedoc of its own: a @typedoc defined before the schema block is kept (with the
marker interpolated in it the same way as in the @moduledoc), and @typedoc false is
respected.
Experimental Features
Two opt-in features are documented in the Experimental Features guide:
- Additional named types:
additional_types: truegenerates a public named type for eachEcto.Enum(and polymorphic embed) field. - PolymorphicEmbed integration:
polymorphic_embeds_one/2andpolymorphic_embeds_many/2support behind a compile-time flag.
Summary
Functions
Replaces Ecto.Schema.embedded_schema/1
Replaces Ecto.Schema.schema/2