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

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

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 by repo.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_changeset filter on it and return error.StaleEntry when 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).

Search Document