View Source Ash.Type behaviour (ash v3.3.3)
The Ash.Type
behaviour is used to define a value type in Ash.
Built in types
:map
-Ash.Type.Map
:keyword
-Ash.Type.Keyword
:term
-Ash.Type.Term
:atom
-Ash.Type.Atom
:string
-Ash.Type.String
:integer
-Ash.Type.Integer
:file
-Ash.Type.File
:float
-Ash.Type.Float
:duration_name
-Ash.Type.DurationName
:function
-Ash.Type.Function
:boolean
-Ash.Type.Boolean
:struct
-Ash.Type.Struct
:uuid
-Ash.Type.UUID
:uuid_v7
-Ash.Type.UUIDv7
:binary
-Ash.Type.Binary
:date
-Ash.Type.Date
:time
-Ash.Type.Time
:decimal
-Ash.Type.Decimal
:ci_string
-Ash.Type.CiString
:naive_datetime
-Ash.Type.NaiveDatetime
:utc_datetime
-Ash.Type.UtcDatetime
:utc_datetime_usec
-Ash.Type.UtcDatetimeUsec
:datetime
-Ash.Type.DateTime
:url_encoded_binary
-Ash.Type.UrlEncodedBinary
:union
-Ash.Type.Union
:module
-Ash.Type.Module
:vector
-Ash.Type.Vector
Lists/Arrays
To specify a list of values, use {:array, Type}
. Arrays are special, and have special constraints:
:items
(term/0
) - Constraints for the elements of the list. See the contained type's docs for more.:min_length
(non_neg_integer/0
) - A minimum length for the items.:max_length
(non_neg_integer/0
) - A maximum length for the items.:nil_items?
(boolean/0
) - Whether or not the list can contain nil items. The default value isfalse
.:remove_nil_items?
(boolean/0
) - Whether or not to remove the nil items from the list instead of adding errors. The default value isfalse
.:empty_values
(list ofterm/0
) - A set of values that, if encountered, will be considered an empty list. The default value is[""]
.
Defining Custom Types
Generally you add use Ash.Type
to your module (it is possible to add @behaviour Ash.Type
and define everything yourself, but this is more work and error-prone).
Overriding the {:array, type}
behaviour. By defining the *_array
versions
of cast_input
, cast_stored
, dump_to_native
and apply_constraints
, you can
override how your type behaves as a collection. This is how the features of embedded
resources are implemented. No need to implement them unless you wish to override the
default behaviour. Your type is responsible for handling nil values in each callback as well.
Simple example of a float custom type
defmodule GenTracker.AshFloat do
use Ash.Type
@impl Ash.Type
def storage_type(_), do: :float
@impl Ash.Type
def cast_input(nil, _), do: {:ok, nil}
def cast_input(value, _) do
Ecto.Type.cast(:float, value)
end
@impl Ash.Type
def cast_stored(nil, _), do: {:ok, nil}
def cast_stored(value, _) do
Ecto.Type.load(:float, value)
end
@impl Ash.Type
def dump_to_native(nil, _), do: {:ok, nil}
def dump_to_native(value, _) do
Ecto.Type.dump(:float, value)
end
end
All the Ash built-in types are implemented with use Ash.Type
so they are good
examples to look at to create your own Ash.Type
.
Short names
You can define short :atom_names
for your custom types by adding them to your Ash configuration:
config :ash, :custom_types, [ash_float: GenTracker.AshFloat]
Doing this will require a recompilation of the :ash
dependency which can be triggered by calling:
$ mix deps.compile ash --force
Composite Types
Composite types are composite in the data layer. Many data layers do not support this, but some (like AshPostgres), do. To define a composite type, the following things should be true:
- A casted value should be a map or struct, for example for a point:
%{x: 1, y: 2}
- The data layer must support composite types, and the data layer representation will be a tuple, i.e
{1, 2}
- Define
def composite?(_), do: true
in your composite type - Define the type & constraints of each item in the tuple, and its name in the map
representation:
def composite_types(_), do: [{:x, :integer, []}, {:y, :integer, []}]
. You can also define a storage key for each item in the tuple, if the underlying type implementation has a different reference for an item, i.edef composite_types(_), do: [{:x, :x_coord, :integer, []}, {:y, :y_coord, :integer, []}]
With the above implemented, your composite type can be used in expressions, for example:
Ash.Query.filter(expr(coordinates[:x] == 1))k
And you can also construct composite types in expressions, for example:
calculate :coordinates, :composite_point, expr(composite_type(%{x: some_value, y: some_other_value}, Point))
Constraints
Constraints are a way of validating an input type. This validation can be used in both attributes and arguments. The kinds of constraints you can apply depends on the type of data. You can find all types in Ash.Type
. Each type has its own page on which the available constraints are listed. For example in Ash.Type.String
you can find 5 constraints:
:max_length
:min_length
:match
:trim?
:allow_empty?
You can also discover these constraints from iex:
$ iex -S mix
iex(1)> Ash.Type.String.constraints
[
max_length: [
type: :non_neg_integer,
doc: "Enforces a maximum length on the value"
],
min_length: [
type: :non_neg_integer,
doc: "Enforces a minimum length on the value"
],
match: [
type: {:custom, Ash.Type.String, :match, []},
doc: "Enforces that the string matches a passed in regex"
],
trim?: [type: :boolean, doc: "Trims the value.", default: true],
allow_empty?: [
type: :boolean,
doc: "If false, the value is set to `nil` if it's empty.",
default: false
]
]
Attribute example
To show how constraints can be used in a attribute, here is an example attribute describing a username:
defmodule MyProject.MyDomain.Account do
# ...
code_interface do
define :create, action: :create
end
actions do
default [:create, :read, :update, :destroy]
end
attributes do
uuid_primary_key :id
attribute :username, :string do
constraints [
max_length: 20,
min_length: 3,
match: ~r/^[a-z_-]*$/,
trim?: true,
allow_empty?: false
]
end
end
# ...
end
If, when creating or updating this attribute, one of the constraints are not met, an error will be given telling you which constraint was broken. See below:
iex(1)> MyProject.MyDomain.Account.create!(%{username: "hi"})
** (Ash.Error.Invalid) Invalid Error
* Invalid value provided for username: length must be greater than or equal to 3.
"hi"
iex(2)> MyProject.MyDomain.Account.create!(%{username: "Hello there this is a long string"})
** (Ash.Error.Invalid) Invalid Error
* Invalid value provided for username: length must be less than or equal to 20.
"Hello there this is a long string"
iex(3)> MyProject.MyDomain.Account.create!(%{username: "hello there"})
** (Ash.Error.Invalid) Invalid Error
* Invalid value provided for username: must match the pattern ~r/^[a-z_-]*$/.
"hello there"
iex(4)> MyProject.MyDomain.Account.create!(%{username: ""})
** (Ash.Error.Invalid) Invalid Error
* attribute title is required
It will give you the resource as usual on successful requests:
iex(5)> MyProject.MyDomain.Account.create!(%{username: "hello"})
#MyProject.MyDomain.Account<
__meta__: #Ecto.Schema.Metadata<:loaded, "account">,
id: "7ba467dd-277c-4916-88ae-f62c93fee7a3",
username: "hello",
...
>
Summary
Callbacks
The implementation for any overloaded implementations.
Useful for typed data layers (like ash_postgres) to instruct them not to attempt to cast input values.
A map of operators with overloaded implementations.
Functions
Confirms if a casted value matches the provided constraints.
Gets the array constraints for a type
Returns true if the value is a builtin type or adopts the Ash.Type
behaviour
Returns true if the type is an ash builtin type
Returns true if the type supports nested loads
Returns true
if the type should be cast in underlying queries
Casts input (e.g. unknown) data to an instance of the type, or errors
Casts a value from the data store to an instance of the type, or errors
Returns true if the type is a composite type
Returns the wrapped composite types
Returns the constraint schema for a type
Calls the type's describe
function with the given constraints
Determine types for a given function or operator.
Casts a value from the Elixir type to a value that can be embedded in another data structure.
Casts a value from the Elixir type to a value that the data store can persist
Returns the ecto compatible type for an Ash.Type.
Returns true if the type is an embedded resource
Determines if two values of a given type are equal.
Returns the StreamData generator for a given type
Gets the load rewrites for a given type, load, calculation and path.
Gets the type module for a given short name or module
Process the old casted values alongside the new casted values.
Handles the change of a given array of values for an attribute change. Runs after casting.
Initializes the constraints according to the underlying type
Detects as a best effort if an arbitrary value matches the given type
Process the old casted values alongside the new uncasted values.
Prepares a given array of values for an attribute change. Runs before casting.
Applies rewrites to a given value.
Returns the list of available type short names
Determines if a type can be compared using the ==
operator.
Returns the underlying storage type (the underlying type of the ecto type of the ash type)
Types
@type constraints() :: Keyword.t()
@type load_context() :: %{ domain: Ash.Domain.t(), actor: term() | nil, tenant: term(), tracer: [Ash.Tracer.t()] | Ash.Tracer.t() | nil, authorize?: boolean() | nil }
@type rewrite() :: {{[atom()], rewrite_data(), atom(), atom()}, source :: term()}
Callbacks
@callback apply_atomic_constraints(new_value :: Ash.Expr.t(), constraints()) :: :ok | {:ok, Ash.Expr.t()} | {:error, Ash.Error.t()}
@callback apply_atomic_constraints_array(new_value :: Ash.Expr.t(), constraints()) :: :ok | {:ok, Ash.Expr.t()} | {:error, Ash.Error.t()}
@callback apply_constraints(term(), constraints()) :: {:ok, new_value :: term()} | :ok | {:error, constraint_error() | [constraint_error()]}
@callback apply_constraints_array([term()], constraints()) :: {:ok, new_values :: [term()]} | :ok | {:error, constraint_error() | [constraint_error()]}
@callback array_constraints() :: constraints()
@callback can_load?(constraints()) :: boolean()
@callback cast_atomic(new_value :: Ash.Expr.t(), constraints()) :: {:atomic, Ash.Expr.t()} | {:error, Ash.Error.t()} | {:not_atomic, String.t()}
@callback cast_atomic_array(new_value :: Ash.Expr.t(), constraints()) :: {:atomic, Ash.Expr.t()} | {:error, Ash.Error.t()} | {:not_atomic, String.t()}
@callback cast_in_query?(constraints()) :: boolean()
@callback cast_input(term(), constraints()) :: {:ok, term()} | error()
@callback cast_input_array([term()], constraints()) :: {:ok, [term()]} | error()
@callback cast_stored(term(), constraints()) :: {:ok, term()} | error()
@callback cast_stored_array([term()], constraints()) :: {:ok, [term()]} | error()
@callback composite?(constraints()) :: boolean()
@callback composite_types(constraints()) :: [ {name, type, constraints()} | {name, storage_key, type, constraints()} ] when name: atom(), type: t(), storage_key: atom()
@callback constraints() :: constraints()
@callback custom_apply_constraints_array?() :: boolean()
@callback describe(constraints()) :: String.t() | nil
@callback dump_to_embedded(term(), constraints()) :: {:ok, term()} | :error
@callback dump_to_embedded_array([term()], constraints()) :: {:ok, term()} | error()
@callback dump_to_native(term(), constraints()) :: {:ok, term()} | error()
@callback dump_to_native_array([term()], constraints()) :: {:ok, term()} | error()
@callback ecto_type() :: Ecto.Type.t()
@callback embedded?() :: boolean()
The implementation for any overloaded implementations.
@callback generator(constraints()) :: Enumerable.t()
get_rewrites(merged_load, calculation, path, constraints)
View Source (optional)@callback get_rewrites( merged_load :: term(), calculation :: Ash.Query.Calculation.t(), path :: [atom()], constraints :: Keyword.t() ) :: [rewrite()]
@callback handle_change(old_term :: term(), new_term :: term(), constraints()) :: {:ok, term()} | error()
@callback handle_change_array(old_term :: [term()], new_term :: [term()], constraints()) :: {:ok, term()} | error()
@callback handle_change_array?() :: boolean()
@callback include_source(constraints(), Ash.Changeset.t()) :: constraints()
@callback init(constraints()) :: {:ok, constraints()} | {:error, Ash.Error.t()}
Useful for typed data layers (like ash_postgres) to instruct them not to attempt to cast input values.
You generally won't need this, but it can be an escape hatch for certain cases.
@callback load( values :: [term()], load :: Keyword.t(), constraints :: Keyword.t(), context :: load_context() ) :: {:ok, [term()]} | {:error, Ash.Error.t()}
@callback matches_type?(term(), constraints()) :: boolean()
A map of operators with overloaded implementations.
These will only be honored if the type is placed in config :ash, :known_types, [...Type]
A corresponding evaluate_operator/1
clause should match.
@callback prepare_change(old_term :: term(), new_uncasted_term :: term(), constraints()) :: {:ok, term()} | error()
prepare_change_array(old_term, new_uncasted_term, constraints)
View Source (optional)@callback prepare_change_array( old_term :: [term()], new_uncasted_term :: [term()], constraints() ) :: {:ok, term()} | error()
@callback prepare_change_array?() :: boolean()
@callback simple_equality?() :: boolean()
@callback storage_type() :: Ecto.Type.t()
@callback storage_type(constraints()) :: Ecto.Type.t()
Functions
@spec apply_atomic_constraints(t(), term(), constraints()) :: {:ok, Ash.Expr.t()} | {:error, Ash.Error.t()}
@spec apply_constraints(t(), term(), constraints()) :: {:ok, term()} | {:error, String.t()}
Confirms if a casted value matches the provided constraints.
Gets the array constraints for a type
Returns true if the value is a builtin type or adopts the Ash.Type
behaviour
Returns true if the type is an ash builtin type
Returns true if the type supports nested loads
@spec cast_atomic(t(), term(), constraints()) :: {:atomic, Ash.Expr.t()} | {:error, Ash.Error.t()} | {:not_atomic, String.t()}
Returns true
if the type should be cast in underlying queries
@spec cast_input(t(), term(), constraints() | nil) :: {:ok, term()} | {:error, Keyword.t()} | :error
Casts input (e.g. unknown) data to an instance of the type, or errors
Maps to Ecto.Type.cast/2
@spec cast_stored(t(), term(), constraints() | nil) :: {:ok, term()} | {:error, keyword()} | :error
Casts a value from the data store to an instance of the type, or errors
Maps to Ecto.Type.load/2
@spec composite?( t(), constraints() ) :: Enumerable.t()
Returns true if the type is a composite type
@spec composite_types( t(), constraints() ) :: Enumerable.t()
Returns the wrapped composite types
@spec constraints(t()) :: constraints()
Returns the constraint schema for a type
Calls the type's describe
function with the given constraints
Determine types for a given function or operator.
@spec dump_to_embedded(t(), term(), constraints() | nil) :: {:ok, term()} | {:error, keyword()} | :error
Casts a value from the Elixir type to a value that can be embedded in another data structure.
Embedded resources expect to be stored in JSON, so this allows things like UUIDs to be stored as strings in embedded resources instead of binary.
@spec dump_to_native(t(), term(), constraints() | nil) :: {:ok, term()} | {:error, keyword()} | :error
Casts a value from the Elixir type to a value that the data store can persist
Maps to Ecto.Type.dump/2
@spec ecto_type(t()) :: Ecto.Type.t()
Returns the ecto compatible type for an Ash.Type.
If you use Ash.Type
, this is created for you. For builtin types
this may return a corresponding ecto builtin type (atom)
Returns true if the type is an embedded resource
Determines if two values of a given type are equal.
Maps to Ecto.Type.equal?/3
@spec generator( module() | {:array, module()}, constraints() ) :: Enumerable.t()
Returns the StreamData generator for a given type
Gets the load rewrites for a given type, load, calculation and path.
This is used for defining types that support a nested load statement. See the embedded type and union type implementations for examples of how to use this.
@spec get_type(atom() | module() | {:array, atom() | module()}) :: atom() | module() | {:array, atom() | module()}
Gets the type module for a given short name or module
Process the old casted values alongside the new casted values.
This is leveraged by embedded types to know if something is being updated or destroyed. This is not called on creates.
Handles the change of a given array of values for an attribute change. Runs after casting.
@spec include_source( t(), Ash.Changeset.t() | Ash.Query.t() | Ash.ActionInput.t(), constraints() ) :: constraints()
@spec init(t(), constraints()) :: {:ok, constraints()} | {:error, Ash.Error.t()}
Initializes the constraints according to the underlying type
@spec load( type :: t(), values :: [term()], load :: Keyword.t(), constraints :: Keyword.t(), context :: load_context() ) :: {:ok, [term()]} | {:error, Ash.Error.t()}
Detects as a best effort if an arbitrary value matches the given type
@spec merge_load( type :: t(), left :: term(), right :: term(), constraints :: Keyword.t(), context :: merge_load_context() | nil ) :: {:ok, [term()]} | :error | {:error, Ash.Error.t()}
Process the old casted values alongside the new uncasted values.
This is leveraged by embedded types to know if something is being updated or destroyed. This is not called on creates.
Prepares a given array of values for an attribute change. Runs before casting.
Applies rewrites to a given value.
This is used for defining types that support a nested load statement. See the embedded type and union type implementations for examples of how to use this.
Returns the list of available type short names
Determines if a type can be compared using the ==
operator.
Returns the underlying storage type (the underlying type of the ecto type of the ash type)