lode/changeset
Changesets: filter, cast, validate, record constraints and optimistic locks on external data before it reaches the database.
A Changeset(row) works in Value-space (like Ecto’s dynamic changes
map) using the schema’s type-erased FieldTypes, while the typed row is
carried as data. Validators are Changeset -> Changeset functions, so they
compose with |> exactly like Ecto’s pipeline.
Types
What a changeset is being applied for. Used by the repo and for constraint
resolution (e.g. the :replace on_replace action).
pub type Action {
Insert
Update
Delete
Replace
Ignore
}
Constructors
-
Insert -
Update -
Delete -
Replace -
Ignore
A pending write to a named association, captured by put_assoc/cast_assoc
and applied by the repo after the parent row is written. The child type is
erased into the closure (built from the schema’s AssocMeta): given the
adapter and the saved parent, it persists the children and returns the parent
with them attached.
pub type AssocChange {
AssocChange(
name: String,
apply: fn(adapter.Adapter, dynamic.Dynamic) -> Result(
dynamic.Dynamic,
error.LodeError,
),
)
}
Constructors
-
AssocChange( name: String, apply: fn(adapter.Adapter, dynamic.Dynamic) -> Result( dynamic.Dynamic, error.LodeError, ), )
A staged belongs_to write: it runs before the owner is inserted, persists
the referenced row, and yields the foreign-key change to apply to the owner.
pub type BeforeAssocChange {
BeforeAssocChange(
name: String,
apply: fn(adapter.Adapter) -> Result(
#(String, value.Value),
error.LodeError,
),
)
}
Constructors
-
BeforeAssocChange( name: String, apply: fn(adapter.Adapter) -> Result( #(String, value.Value), error.LodeError, ), )
pub type Changeset(row) {
Changeset(
data: row,
schema: schema.Schema(row),
changes: dict.Dict(String, value.Value),
errors: List(#(String, error.FieldError)),
valid: Bool,
required: List(String),
constraints: List(Constraint),
action: option.Option(Action),
prefix: option.Option(String),
lock: option.Option(#(String, value.Value)),
assoc_changes: List(AssocChange),
before_assoc_changes: List(BeforeAssocChange),
)
}
Constructors
-
Changeset( data: row, schema: schema.Schema(row), changes: dict.Dict(String, value.Value), errors: List(#(String, error.FieldError)), valid: Bool, required: List(String), constraints: List(Constraint), action: option.Option(Action), prefix: option.Option(String), lock: option.Option(#(String, value.Value)), assoc_changes: List(AssocChange), before_assoc_changes: List(BeforeAssocChange), )Arguments
- changes
-
Casted, DB-canonical changes keyed by field name.
- errors
-
Accumulated errors, newest first.
- prefix
-
Optional Postgres schema prefix for writes (Ecto’s struct-meta prefix); set with
put_prefix, applied byrepo.insert/repo.update. - lock
-
Optimistic-lock filter recorded by
optimistic_lock: the lock column and the version value the write must match.repo.update/repo.delete_changesetfilter on it and returnerror.StaleEntrywhen no row matches. - assoc_changes
-
Pending association writes that run after the owner is saved (
has_many/has_one/many_to_many). - before_assoc_changes
-
Pending association writes that run before the owner is saved (
belongs_to).
A recorded constraint to check against the adapter’s reported violations after a failed write. Pure data here; resolution happens in the repo layer.
pub type Constraint {
Constraint(
kind: error.ConstraintKind,
name: String,
field: String,
message: String,
)
}
Constructors
-
Constraint( kind: error.ConstraintKind, name: String, field: String, message: String, )Arguments
- name
-
The database constraint name to match against.
- field
-
The changeset field the error is attributed to.
pub type NumberCheck {
GreaterThan(Float)
GreaterThanOrEqual(Float)
LessThan(Float)
LessThanOrEqual(Float)
EqualTo(Float)
NotEqualTo(Float)
}
Constructors
-
GreaterThan(Float) -
GreaterThanOrEqual(Float) -
LessThan(Float) -
LessThanOrEqual(Float) -
EqualTo(Float) -
NotEqualTo(Float)
Values
pub fn add_error(
changeset cs: Changeset(row),
field field: String,
message message: String,
meta meta: List(#(String, String)),
) -> Changeset(row)
Add an error under an error key (a column, an association, or a synthetic
key like base), marking the changeset invalid. The key is a plain string
because errors attach to more than just columns; for a typed column use
field.name(person_email()).
pub fn apply_action(
changeset cs: Changeset(row),
action action: Action,
) -> Result(row, error.LodeError)
Set the action and return the row only if the changeset is valid; otherwise
return the accumulated errors (mirrors apply_action/2).
pub fn apply_changes(
cs: Changeset(row),
) -> Result(row, error.LodeError)
Merge changes onto data and rebuild the typed row, regardless of validity
(mirrors Ecto.Changeset.apply_changes/1).
pub fn cast(
data data: row,
schema schema: schema.Schema(row),
params params: dict.Dict(String, value.Value),
permitted f: List(field.Field(row)),
) -> Changeset(row)
Cast params into changes, keeping only permitted fields and validating
each against its field type. Unknown/absent params are skipped; cast failures
add an is invalid error.
Params are keyed by field name; the recorded changes are keyed by the
field’s column source (which defaults to the name), so changes line up
with the column-keyed maps dump produces and the adapters consume — a
with_source rename works end-to-end.
pub fn cast_assoc(
changeset cs: Changeset(parent),
name name: String,
data data: child,
params params: List(dict.Dict(String, value.Value)),
with with: fn(child, dict.Dict(String, value.Value)) -> Changeset(
child,
),
) -> Changeset(parent)
Build child changesets from a list of param maps and stage them for writing
(Ecto’s cast_assoc(..., with:)). data is the base record new children are
built from; with turns each param map into a child changeset (typically a
cast). If any child changeset is invalid the parent is marked invalid,
with an is invalid error on the association plus each invalid child’s own
errors re-keyed "<name>.<field>" (tagged association/child_index).
changeset.change(post, post_schema()) |> changeset.cast_assoc( name: “comments”, data: Comment(0, 0, 0, “”, None), params: comment_params, with: fn(c, p) { changeset.cast(c, comment_schema(), p, [“body”]) }, )
pub fn cast_assoc_by_key(
changeset cs: Changeset(parent),
name name: String,
existing existing: List(child),
blank blank: child,
params params: List(dict.Dict(String, value.Value)),
key_param key_param: String,
child_key child_key: fn(child) -> value.Value,
with with: fn(child, dict.Dict(String, value.Value)) -> Changeset(
child,
),
) -> Changeset(parent)
cast_assoc with id-based matching (Ecto’s default): each param map
whose key_param (e.g. "id") equals an existing child’s child_key casts
that child — an update preserving its other columns — while a param with
no match casts blank (an insert). Existing children matched by no param
are left to the association’s on_replace policy when the parent is written.
Pass the currently-loaded children as existing (e.g. from a preload).
Invalid children mark the parent invalid exactly like cast_assoc
(association-level is invalid plus "<name>.<field>" child errors,
child_index counting params order).
pub fn change(
data data: row,
schema schema: schema.Schema(row),
) -> Changeset(row)
Start an empty changeset over data (no changes, valid).
pub fn check_constraint(
changeset cs: Changeset(row),
field f: field.Field(row),
name name: String,
message message: String,
) -> Changeset(row)
pub fn constraint_to_error(
changeset cs: Changeset(row),
error err: error.LodeError,
) -> option.Option(error.LodeError)
Convert a database ConstraintError into a ChangesetInvalid when the
changeset declared a constraint with the same database name — mirroring how
Ecto turns a constraint violation into {:error, changeset} with a field
error (e.g. "has already been taken") ready for form rendering. Returns
None when no declared constraint matches, so the repo propagates the raw
ConstraintError. Used by repo.insert/repo.update. The many_to_many
join-table twin is association.join_constraint.
pub fn exclusion_constraint(
changeset cs: Changeset(row),
field f: field.Field(row),
name name: String,
) -> Changeset(row)
Map an exclusion-constraint violation (Postgres EXCLUDE) to a field error
(Ecto’s exclusion_constraint).
pub fn foreign_key_constraint(
changeset cs: Changeset(row),
field f: field.Field(row),
name name: String,
) -> Changeset(row)
pub fn get_change(
changeset cs: Changeset(row),
name f: field.Field(row),
) -> option.Option(value.Value)
A pending change for a field, if any (does not fall back to data).
pub fn get_field(
changeset cs: Changeset(row),
name f: field.Field(row),
) -> option.Option(value.Value)
The effective value of a field: a pending change if present, else the value
from data. None if absent or NULL.
pub fn no_assoc_constraint(
changeset cs: Changeset(row),
field f: field.Field(row),
name name: String,
) -> Changeset(row)
Map a foreign-key violation triggered by still-associated rows to a field
error (Ecto’s no_assoc_constraint) — e.g. deleting a parent that a child
still references. The name is the child’s foreign-key constraint.
pub fn optimistic_lock(
changeset cs: Changeset(row),
field f: field.Field(row),
) -> Changeset(row)
Apply an optimistic lock on field (Ecto’s optimistic_lock/3): the
current version is read from the changeset’s data, repo.update /
repo.delete_changeset add WHERE <field> = <current> to the write, and
an update also SETs <field> = <current> + 1. When another writer got
there first the version no longer matches, zero rows are affected, and the
write returns Error(error.StaleEntry) — Ecto’s raised
Ecto.StaleEntryError as a Result.
The field must be an integer column with a value in data (give it a
database default, e.g. 1); anything else marks the changeset invalid.
The incrementer is a fixed + 1.
changeset.change(post, post_schema()) |> changeset.put_change_typed(field.field(“title”), primitive.string(), t) |> changeset.optimistic_lock(field.field(“lock_version”))
pub fn put_assoc(
changeset cs: Changeset(parent),
name name: String,
children children: List(Changeset(child)),
) -> Changeset(parent)
Stage child rows to be written for a named association when the parent
changeset is inserted/updated (Ecto’s put_assoc). The association must be
registered on the parent schema as writable (has_many/has_one); the
children are erased and persisted by the repo after the parent is saved,
honoring the association’s on_replace policy.
If any staged child changeset is invalid, the repo write fails with
ChangesetInvalid before any child row is written: every invalid child’s
errors are re-keyed "<name>.<field>" and tagged with association and
child_index (its 0-based position in children) metadata.
changeset.change(post, post_schema()) |> changeset.put_assoc(“comments”, [ changeset.change(Comment(..), comment_schema()), ])
pub fn put_change(
changeset cs: Changeset(row),
name f: field.Field(row),
value v: value.Value,
) -> Changeset(row)
Put a raw Value change directly under a column key (no casting, no
name-to-source mapping). Prefer put_change_typed.
pub fn put_change_typed(
changeset cs: Changeset(row),
name f: field.Field(row),
of t: type_.LodeType(a),
value v: a,
) -> Changeset(row)
Put a typed change, casting through the given LodeType and recording an
error on failure. Like cast, the change is stored under the field’s
column source.
pub fn put_prefix(
changeset cs: Changeset(row),
schema schema: String,
) -> Changeset(row)
Set the Postgres schema prefix for writes built from this changeset (Ecto’s
struct-meta prefix). repo.insert/repo.update then render the table as
"<schema>"."<table>". Apply it when building the changeset, or inline at
the call site to override.
pub fn sort_drop_params(
params params: List(dict.Dict(String, value.Value)),
key_param key_param: String,
sort sort: List(value.Value),
drop drop: List(value.Value),
) -> List(dict.Dict(String, value.Value))
Reorder and filter child param maps before cast_assoc (Ecto’s
:sort_param / :drop_param). Identify each param by its key_param
value; drop removes params whose key is listed, and sort lists keys in
the desired order (params whose key isn’t in sort, including brand-new
ones, keep their relative order after the sorted ones).
pub fn unique_constraint(
changeset cs: Changeset(row),
field f: field.Field(row),
name name: String,
) -> Changeset(row)
pub fn validate_acceptance(
changeset cs: Changeset(row),
field f: field.Field(row),
) -> Changeset(row)
Validate that the field was changed to true (mirrors
Ecto.Changeset.validate_acceptance/3 — terms-of-service checkboxes).
Only a change counts: an absent or false value is rejected. The field is
typically a schema.virtual_field of primitive.boolean().
pub fn validate_change(
changeset cs: Changeset(row),
field f: field.Field(row),
validator validator: fn(value.Value) -> List(error.FieldError),
) -> Changeset(row)
Run a custom validation, returning a list of FieldErrors for the field.
pub fn validate_confirmation(
changeset cs: Changeset(row),
field f: field.Field(row),
) -> Changeset(row)
Validate that a change matches its <field>_confirmation change (mirrors
Ecto.Changeset.validate_confirmation/3). Declare the confirmation field
on the schema — typically as a schema.virtual_field — and cast it, or the
comparison never sees it. Like Ecto’s default, a missing confirmation is
accepted; pair with validate_required([field <> "_confirmation"]) to
insist on one.
pub fn validate_exclusion(
changeset cs: Changeset(row),
field f: field.Field(row),
forbidden forbidden: List(value.Value),
) -> Changeset(row)
Validate that a field’s value is NOT one of forbidden.
pub fn validate_format(
changeset cs: Changeset(row),
field f: field.Field(row),
with with: regexp.Regexp,
) -> Changeset(row)
Validate that a string field’s pending change matches a regular
expression (mirrors Ecto.Changeset.validate_format/4 — like Ecto, only a
change is validated, never the existing data).
pub fn validate_inclusion(
changeset cs: Changeset(row),
field f: field.Field(row),
allowed allowed: List(value.Value),
) -> Changeset(row)
Validate that a field’s value is one of allowed.
pub fn validate_length(
changeset cs: Changeset(row),
field f: field.Field(row),
min min: option.Option(Int),
max max: option.Option(Int),
) -> Changeset(row)
Validate that a string field’s length is within optional min/max bounds. (For list fields, counts elements.)
pub fn validate_number(
changeset cs: Changeset(row),
field f: field.Field(row),
checks checks: List(NumberCheck),
) -> Changeset(row)
Validate that a numeric field satisfies all given comparisons.
pub fn validate_required(
changeset cs: Changeset(row),
fields f: List(field.Field(row)),
) -> Changeset(row)
Require that each named field has a present, non-blank value.
pub fn validate_subset(
changeset cs: Changeset(row),
field f: field.Field(row),
of allowed: List(value.Value),
) -> Changeset(row)
Validate that every element of a list field is one of allowed (mirrors
Ecto.Changeset.validate_subset/4).