data Rotor::Changeset[M <: Rotor::Model](data: M, changes: Hash[Symbol, Int | Float | String | Bool | Time | Bytes | Nil], errors: Array[Rotor::FieldError], params: Hash[String, String | Int | Float | Bool | Time | Bytes | Nil], permitted: Array[Symbol], constraints: Array[Rotor::Constraint], assocs: Hash[Symbol, Array[Rotor::Model]], action: Symbol?, cast: Bool)

Changes to a model from untrusted input (a form, JSON), cast to the columns' types and validated, with the errors meant for a user, as Ecto's changesets: an immutable value, each validation giving a new one; Rotor::Repo#insert and Rotor::Repo#update take it and answer with a Rotor::Outcome.

class CsUser < Rotor::Model
  table "users" do
    column :id, Int, primary: true
    column :email, String
    column :age, Int, null: true
  end
end

cs = Rotor::Changeset.cast(CsUser, {"email" => "ann@example.com", "age" => "x"}, [:email, :age])
  .validate_required([:email])
  .validate_format(:email, /@/)
cs.valid?               # => false
cs.errors_on(:age)      # => ["is invalid"]
cs.changes              # => {email: "ann@example.com"}

/home/runner/work/rotor/rotor/src/changeset.qz:75

Class methods

def self.cast[T <: Rotor::Model](model: T, params: Hash[String, Object?], permitted: Array[Symbol]) -> Rotor::Changeset[T]

A changeset of model with the permitted fields of params (form or JSON input, by name) cast to their columns' types; the other parameters are ignored. A value that does not cast is an is invalid error (validation: :cast) and no change; a string of spaces or nothing is nil, except for a String column that admits no NULL; nil for a column that admits none is can't be blank (:required). Raises Rotor::SchemaError for a permitted name that is not a column.

/home/runner/work/rotor/rotor/src/changeset.qz:84

def self.cast[T <: Rotor::Model](model: T, params: Hash[Symbol, Object?], permitted: Array[Symbol]) -> Rotor::Changeset[T]

A changeset of model with the permitted fields of params (form or JSON input, by name) cast to their columns' types; the other parameters are ignored. A value that does not cast is an is invalid error (validation: :cast) and no change; a string of spaces or nothing is nil, except for a String column that admits no NULL; nil for a column that admits none is can't be blank (:required). Raises Rotor::SchemaError for a permitted name that is not a column.

/home/runner/work/rotor/rotor/src/changeset.qz:117

def self.cast[T <: Rotor::Model](model: Type[T], params: Hash[String, Object?], permitted: Array[Symbol]) -> Rotor::Changeset[T]

A changeset of a new model of class model (its new: the columns' defaults).

/home/runner/work/rotor/rotor/src/changeset.qz:125

def self.cast[T <: Rotor::Model](model: Type[T], params: Hash[Symbol, Object?], permitted: Array[Symbol]) -> Rotor::Changeset[T]

A changeset of a new model of class model (its new: the columns' defaults).

/home/runner/work/rotor/rotor/src/changeset.qz:128

def self.change[T <: Rotor::Model](model: T) -> Rotor::Changeset[T]

A changeset of model without parameters: for put_change, put_assoc and validations of code's own values.

/home/runner/work/rotor/rotor/src/changeset.qz:132

def self.cast_value(c: Rotor::Column, raw: Object?) -> [Bool, Int | Float | String | Bool | Time | Bytes | Nil]

[true, value] for raw cast to column c's type, [false, nil] when it does not cast.

/home/runner/work/rotor/rotor/src/changeset.qz:136

def self.int?(s: String) -> Bool

Whether s is an Int in decimal: ASCII digits, within 64 bits.

/home/runner/work/rotor/rotor/src/changeset.qz:538

def self.with_zone(s: String) -> String

An ISO 8601 time as Time.iso8601 reads it: a space between the date and the time taken as T, and a time without an offset (an HTML datetime-local, 2026-11-07T10:00) read as UTC, as Ecto.

/home/runner/work/rotor/rotor/src/changeset.qz:549

def self.same_class?(c: Rotor::Column, v: Int | Float | String | Bool | Time | Bytes) -> Bool

Whether v is of column c's class exactly (a change put by code).

/home/runner/work/rotor/rotor/src/changeset.qz:556

Methods

def data -> M

/home/runner/work/rotor/rotor/src/changeset.qz:75

def changes -> Hash[Symbol, Int | Float | String | Bool | Time | Bytes | Nil]

/home/runner/work/rotor/rotor/src/changeset.qz:75

def errors -> Array[Rotor::FieldError]

/home/runner/work/rotor/rotor/src/changeset.qz:75

def params -> Hash[String, String | Int | Float | Bool | Time | Bytes | Nil]

/home/runner/work/rotor/rotor/src/changeset.qz:75

def permitted -> Array[Symbol]

/home/runner/work/rotor/rotor/src/changeset.qz:75

def constraints -> Array[Rotor::Constraint]

/home/runner/work/rotor/rotor/src/changeset.qz:75

def assocs -> Hash[Symbol, Array[Rotor::Model]]

/home/runner/work/rotor/rotor/src/changeset.qz:75

def action -> Symbol?

/home/runner/work/rotor/rotor/src/changeset.qz:75

def cast -> Bool

/home/runner/work/rotor/rotor/src/changeset.qz:75

def with(*, data: M = …, changes: Hash[Symbol, Int | Float | String | Bool | Time | Bytes | Nil] = …, errors: Array[Rotor::FieldError] = …, params: Hash[String, String | Int | Float | Bool | Time | Bytes | Nil] = …, permitted: Array[Symbol] = …, constraints: Array[Rotor::Constraint] = …, assocs: Hash[Symbol, Array[Rotor::Model]] = …, action: Symbol? = …, cast: Bool = …) -> Rotor::Changeset[M]

A copy with the fields given replaced.

/home/runner/work/rotor/rotor/src/changeset.qz:84

def valid? -> Bool

Whether it has no error.

/home/runner/work/rotor/rotor/src/changeset.qz:187

def errors_on(field: Symbol) -> Array[String]

The messages of the errors on field, their placeholders filled.

/home/runner/work/rotor/rotor/src/changeset.qz:190

def get_change(field: Symbol) -> Int | Float | String | Bool | Time | Bytes | Nil

The change of field, or nil.

/home/runner/work/rotor/rotor/src/changeset.qz:193

def get_field(field: Symbol) -> Int | Float | String | Bool | Time | Bytes | Nil

The value field would have: its change, else the data's (nil when it has none).

/home/runner/work/rotor/rotor/src/changeset.qz:197

def put_change(field: Symbol, value: Object?) -> Rotor::Changeset[M]

With value as the change of field (no change when it equals the data's), checked against the column's type.

/home/runner/work/rotor/rotor/src/changeset.qz:203

def add_error(field: Symbol, message: String, *, validation: Symbol = …, meta: Hash[Symbol, Int | Float | String | Symbol | Array[Int | Float | String | Bool | Time | Bytes]] = …) -> Rotor::Changeset[M]

With an error on field (:base for the whole).

/home/runner/work/rotor/rotor/src/changeset.qz:218

def apply -> M?

The model with the changes, or nil when there are errors.

/home/runner/work/rotor/rotor/src/changeset.qz:223

def apply! -> M

The model with the changes; raises Rotor::InvalidChangeset when there are errors.

/home/runner/work/rotor/rotor/src/changeset.qz:229

def validate_required(fields: Array[Symbol], *, message: String = …) -> Rotor::Changeset[M]

Each of fields must have a value: not nil, not a blank string (can't be blank, :required). A field with an error already is skipped.

/home/runner/work/rotor/rotor/src/changeset.qz:245

def validate_length(field: Symbol, *, min: Int? = …, max: Int? = …, is: Int? = …) -> Rotor::Changeset[M]

The change of field, a String, must have between min and max characters (grapheme clusters, what a reader sees as one, as Ecto counts), or is (:length, with count and kind).

/home/runner/work/rotor/rotor/src/changeset.qz:260

def validate_format(field: Symbol, pattern: Regexp, *, message: String = …) -> Rotor::Changeset[M]

The change of field, a String, must match pattern (has invalid format, :format).

/home/runner/work/rotor/rotor/src/changeset.qz:278

def validate_number(field: Symbol, *, min: Int | Float | Nil = …, max: Int | Float | Nil = …, greater_than: Int | Float | Nil = …, less_than: Int | Float | Nil = …) -> Rotor::Changeset[M]

The change of field, a number, must be within the bounds given (:number, with kind and number).

/home/runner/work/rotor/rotor/src/changeset.qz:286

def validate_inclusion(field: Symbol, values: Array[Int | Float | String | Bool | Time | Bytes], *, message: String = …) -> Rotor::Changeset[M]

The change of field must be one of values (is invalid, :inclusion, with enum).

/home/runner/work/rotor/rotor/src/changeset.qz:327

def validate_exclusion(field: Symbol, values: Array[Int | Float | String | Bool | Time | Bytes], *, message: String = …) -> Rotor::Changeset[M]

The change of field must not be one of values (is reserved, :exclusion, with enum).

/home/runner/work/rotor/rotor/src/changeset.qz:335

def validate_acceptance(field: Symbol, *, message: String = …) -> Rotor::Changeset[M]

The parameter field (a checkbox, not a column) must be true: true, "true", "1" or "on" (must be accepted, :acceptance); a missing one is not. Skipped for a changeset made by Rotor::Changeset.change (no input), as Ecto.

/home/runner/work/rotor/rotor/src/changeset.qz:345

def validate_confirmation(field: Symbol, *, required: Bool = …, message: String = …) -> Rotor::Changeset[M]

The parameter <field>_confirmation, cast as field is, must equal the change of field (does not match confirmation, :confirmation, on the confirmation's field); a missing one is an error with required: true only.

/home/runner/work/rotor/rotor/src/changeset.qz:361

def validate(field: Symbol, &: (M) -> String?) -> Rotor::Changeset[M]

A rule of the change of field: the block gets the model with the changes and gives a message, or nil when it holds (:custom). Runs only when field changed and cast.

class CsAccount < Rotor::Model
  table "accounts" do
    column :id, Int, primary: true
    column :email, String
  end
end

cs = Rotor::Changeset.cast(CsAccount, {"email" => "root@example.com"}, [:email])
  .validate(:email) { |a| a.email.start_with?("root@") ? "is reserved" : nil }
cs.errors_on(:email)  # => ["is reserved"]

/home/runner/work/rotor/rotor/src/changeset.qz:388

def validate(&: (Rotor::Changeset[M]) -> Rotor::Changeset[M]) -> Rotor::Changeset[M]

A rule of the whole: the block gives the changeset with the errors it adds (cs.add_error(...)).

/home/runner/work/rotor/rotor/src/changeset.qz:394

def validate_change(field: Symbol, validation: Symbol, &: (M) -> String?) -> Rotor::Changeset[M]

As validate(field), the error's validation named.

/home/runner/work/rotor/rotor/src/changeset.qz:397

def unsafe_validate_unique(fields: Array[Symbol], repo: Rotor::Repo, *, query: Rotor::Query[M]? = …, message: String = …) -> Rotor::Changeset[M]

Whether a row of the table has the values fields would have, by a query: an error on the first field (has already been taken, :unsafe_unique) before the database's unique index refuses the row. Racy (two saves may pass it at once): no replacement for the index and its unique_constraint. Checked when one of the fields changed and none has an error; nil values never match. query narrows the rows looked at.

/home/runner/work/rotor/rotor/src/changeset.qz:412

def unique_constraint(field: Symbol, *, name: String? = …, message: String = …) -> Rotor::Changeset[M]

On save, a unique violation of the index name (by default <table>_<field>_index, the name create_index gives) is an error on field (has already been taken, :unique).

/home/runner/work/rotor/rotor/src/changeset.qz:442

def foreign_key_constraint(field: Symbol, *, name: String? = …, message: String = …) -> Rotor::Changeset[M]

On save, a foreign key violation of name (by default <table>_<field>_fkey, PostgreSQL's) is an error on field (does not exist, :foreign_key).

/home/runner/work/rotor/rotor/src/changeset.qz:449

def check_constraint(field: Symbol, *, name: String, message: String = …) -> Rotor::Changeset[M]

On save, a violation of the check constraint name is an error on field (is invalid, :check).

/home/runner/work/rotor/rotor/src/changeset.qz:455

def no_assoc_constraint(assoc: Symbol, *, name: String? = …, message: String = …) -> Rotor::Changeset[M]

On save (a delete), a violation of the foreign key from the association assoc's table (a has_many; by default <its table>_<its key>_fkey) is an error on assoc (are still associated with this entry, :no_assoc).

/home/runner/work/rotor/rotor/src/changeset.qz:463

def put_assoc(name: Symbol, models: Array[Rotor::Model]) -> Rotor::Changeset[M]

On save, the has_many ..., through: association name is made exactly models (persisted ones: their key is read): the join rows for others deleted, the missing ones inserted, and the saved model has models preloaded as name. Two saves of one model at once may both insert a pair: a unique index on the join table's two keys stops that.

/home/runner/work/rotor/rotor/src/changeset.qz:476

def summary -> String

User is invalid: email can't be blank; age is invalid.

/home/runner/work/rotor/rotor/src/changeset.qz:487

def with_action(a: Symbol) -> Rotor::Changeset[M]

The changeset with action (set by the repository on save).

/home/runner/work/rotor/rotor/src/changeset.qz:492

def constraint_error(e: PG::ServerError) -> Rotor::FieldError?

The error a violation e of a declared constraint is, or nil.

/home/runner/work/rotor/rotor/src/changeset.qz:495

def to_h -> Hash[Symbol, String]

The values by field, a field declared redact: true shown as **redacted**: the changes, the errors, the action.

/home/runner/work/rotor/rotor/src/changeset.qz:503

def inspect -> String

#<Rotor::Changeset User changes: {...} errors: [...] valid: false>, a field declared redact: true hidden.

/home/runner/work/rotor/rotor/src/changeset.qz:509

def to_s -> String

As inspect.

/home/runner/work/rotor/rotor/src/changeset.qz:514