---
title: Models
description: Defining models in .glacier, their fields and types, and creating, reading, updating and deleting records with validations and callbacks.
category: Models
order: 1
---
Models are the M in MVC: a model maps a database table to a Rust struct,
and each record is one row. A record can be built, changed and saved in
place, and keeps track of its own errors and changes. You write models in `.glacier`, and
the app's build compiles each one to an ordinary Rust struct.
After reading this guide, you will know:
- How to generate and define a model, and how it maps to a table.
- Which field types and field options a model can have.
- How to create, read, update and delete records.
- How validations and callbacks work, and in which order they run.
- How timestamps are read from params, and how to work with times on a
user's clock.
- How to write a model in plain Rust instead.
The examples come from a small library app with authors, books, sections,
tags, members, loans and reviews. Its tests run every example in this guide.
## Generating a Model
`glacier generate model` writes a model, a migration to create its table,
a factory and a test:
```shell
$ glacier generate model Book title:string isbn:string pages:integer author:references section:references
create db/migrations/m20261004100639_create_books.glacier
create src/models/book.glacier
create tests/factories/book.glacier
create tests/models/book.test.glacier
update tests/glacier.rs
Next: glacier db migrate
```
The field types it understands are `string`, `text`, `integer`, `bigint`,
`float`, `boolean` and `references`. A string or text field can be made
nullable with `:optional` (`bio:text:optional`), and any but a boolean or
a reference made unique with `:unique` (`email:string:unique`): the model
validates it and the migration adds a unique index. In a `.glacier` model, a
`references` field becomes a `belongs_to` association and an `<name>_id`
column; with `--dialect rust`, the generator writes only the `<name>_id`
field.
There's no list of models to keep: every file in `src/models/` is found
when the app builds.
Run `glacier db migrate` to create the table, as the
[Migrations](migrations.html) guide describes.
## Defining Models
A model is a module that `implements Model`, in
`src/models/<name>.glacier` (`model Name do … end` is the short form).
The library's models started out as the generator wrote them, then grew
the features this guide describes. Here is `Author`:
```glacier
module Author do
implements Model
field name: String, required, normalize: fn n -> n.trim().to_owned() end
field bio: Option<String>
has_many :books, order: :title
has_many :translations, Book, foreign_key: :translator_id
has_one :latest_book, Book, latest: :id
has_many :comments, as: :commentable
# Each book's reviews, by way of the author's books.
has_many :reviews, through: :books
def display_name(self) -> String do
"#{self.name} (#{self.books.len()} books)"
end
before_destroy do
Book.where(author_id: self.id).delete_all()!
end
end
```
Fields and associations come first. Scopes, methods and callbacks follow,
with all of a model's scopes together and all of its callbacks together;
the compiler reports an error otherwise.
### Naming Conventions
The table is the model's name in snake_case, pluralised with English's
common rules and irregular words: `Author` reads and writes `authors`, a `BlogPost` model would
use `blog_posts`, and `Person` and `Shelf` would use `people` and
`shelves`. When the convention doesn't fit, give the table's name:
`implements Model, table: "staff"`.
### Ids and Timestamps
Every model gets an `id: i64` primary key, and two timestamps,
`inserted_at` and `updated_at`, which Glacier sets when a record is saved.
You don't declare them:
```glacier
assert author.inserted_at == author.updated_at
assert author.inserted_at <= Timestamp.now()
```
A model without timestamps says so, and its table then needs no
timestamp columns:
```glacier
module Tag do
implements Model, timestamps: false
field name: String, required, unique
end
```
`timestamps: :rails` maps the `created_at` and `updated_at` columns of a
schema a Rails app created, as `NaiveTimestamp` (a time without a zone,
as Rails stores them). A field you declare yourself wins over the one Glacier would add,
so `field id: i32` maps an `integer` key, and `field created_at:
Option<NaiveTimestamp>` a nullable one. The [Migrations](migrations.html)
guide has more on mapping an existing schema.
### Field Types
A field's type is a Rust type. These are the ones a column can be read
into:
| `String` | text | `Option<String>` for a nullable column |
| `bool` | boolean | |
| `i32`, `i64` | integer, bigint | `i16` works too |
| `f64` | double precision / real | |
| `Decimal` | numeric | exact on Postgres; stored as a REAL on SQLite |
| `Timestamp` | timestamptz (Postgres), text (SQLite) | a UTC time |
| `NaiveTimestamp` | timestamp without time zone | |
| `Date` | date | |
| `JsonValue` | json / jsonb (Postgres), text (SQLite) | |
| `Uuid` | uuid | |
| `Vec<u8>` | bytea / blob | |
| `enum(…)` | text | see [Enums](#enums) |
Wrap any of them in `Option<…>` when the column is nullable. A `Decimal`
holds an amount exactly, and prints without trailing zeros but with at
least one decimal place (`50.0`):
```glacier
book = insert(Book, price: "12.50")
assert book.price.unwrap().to_s() == "12.5"
```
A `Decimal` has no fixed precision. Adding, subtracting and multiplying
are exact. Dividing is exact when the quotient ends within the digits it keeps, as
`100 / 8` does; otherwise the quotient keeps a number of digits worked out from
the sizes and precision of the two numbers (36 decimal places for `1 /
3`) and its last digit is rounded. Dividing by zero panics:
```glacier
tenth = "0.1".parse::<Decimal>().unwrap()
fifth = "0.2".parse::<Decimal>().unwrap()
sum = tenth + fifth
assert sum.to_s() == "0.3"
share = Decimal.from(100) / Decimal.from(8)
assert share.to_s() == "12.5"
third = Decimal.from(1) / Decimal.from(3)
assert third.to_s() == "0.333333333333333333333333333333333333"
two_thirds = Decimal.from(2) / Decimal.from(3)
assert two_thirds.to_s() == "0.666666666666666666666666666666666667"
```
Decimals can be summed, so the `Book` model's `average_price` function,
shown in the next section, adds up the books' prices and divides by how
many there are. (The database can average a column too, but
`Book.average(:price)` returns an `f64`, as the
[Querying](querying.html#calculations) guide describes, and
`average_price` keeps the result exact.) Its first line,
`books.filter_map(:price)`, is the prices that are set:
```glacier
books = Book.all()!
assert Book.average_price(&books).unwrap().to_s() == "10.496666666666666666666666666666666667"
```
### Field Options (updated)
Options follow a field's type, separated by commas. Here is the `Book`
model, which uses most of them:
```glacier
module Book do
implements Model
field title: String, required, length(max: 200), unique(scope: :author_id)
field isbn: String, required, unique, length(is: 13)
field slug: String
field pages: i32, numericality(greater_than: 0)
field price: Option<Decimal>
field status: enum(draft, published, out_of_print), default: :draft
field featured: bool, protected
belongs_to :author
belongs_to :section, default: Section.order(:position).first()!
belongs_to :translated_by, Author, foreign_key: :translator_id, optional
has_many :tags, through: :books_tags
has_many :reviews, nested, allow_destroy
has_many :comments, as: :commentable
scope published, where(status: :published)
scope by(author_id: i64), where(author_id: author_id)
scope longer_than(pages: i32), filter(fn b -> b.pages.gt(pages) end)
def summary(self) -> String do
"#{self.title} (#{self.pages} pages)"
end
def publish(self) do
self.update(status: :published)!
end
def average_price(books: &[Book]) -> Option<Decimal> do
prices = books.filter_map(:price)
if prices.is_empty() do
return None
end
count = Decimal.from(prices.len() as i64)
Some(prices.sum(fn p -> p.clone() end) / count)
end
before_save do
self.slug = self.title.to_lowercase().replace(" ", "-")
end
end
```
| `required` | must be present: not `None`, and not empty or only whitespace |
| `required(message: "…")` | the same, with your own message |
| `unique` | no other row may have the same value |
| `unique(scope: :author_id)` | unique among rows with the same `author_id`; `scope: [:a, :b]` takes several |
| `length(min: 3, max: 200)` | text length in characters; `min:`, `max:` or `is:` |
| `format: "^[a-z-]+$"` | text must match a regular expression |
| `inclusion: [:draft, :published]` | must be one of the values, compared as text; atoms or strings |
| `numericality(greater_than: 0)` | must be a number; `numericality` alone, or with `greater_than:`, `greater_than_or_equal_to:`, `less_than:`, `less_than_or_equal_to:` and `only_integer: true` |
| `with: check` | runs your own function on the field's value: `fn check(value: Option<&Value>) -> Option<String>`, returning an error message or `None` |
| `protected` | never set from request params |
| `encrypted` | stored encrypted; see [Encrypted Fields](#encrypted-fields) |
| `encrypted(deterministic)` | stored encrypted, and still searchable |
| `hidden` | never written to JSON; see [Hidden Fields](#hidden-fields) |
| `virtual` | set from params like a field, but never stored |
| `default: :draft` | for `enum(…)` fields: the value a new record starts with |
| `normalize: fn n -> n.trim().to_owned() end` | the value's normal form, set before validation on every save (an `Option`'s value when there is one) |
| `confirmation` | must match `<field>_confirmation` when that's given; see [Confirmation and Acceptance](#confirmation-and-acceptance) |
| `acceptance` | a checkbox that must be ticked when it's sent |
`normalize:` keeps a field in one form however it's typed, as Rails'
`normalizes` does. `Author` trims its name, so a name of only spaces is
blank:
```glacier
field name: String, required, normalize: fn n -> n.trim().to_owned() end
```
```glacier
test "an author's name is kept trimmed" do
author = Author.create(params_for(Author, name: " Ursula K. Le Guin "))!.unwrap()
assert author.name == "Ursula K. Le Guin"
# Only spaces is blank once trimmed.
invalid = Author.create(params_for(Author, name: " "))!.unwrap_invalid()
assert invalid.errors_on("name") == ["can't be blank"]
end
```
`default:` is only for enums; other defaults belong in the migration, so
that the database fills them in. The [Validations](#validations) section
shows these options at work.
### Enums
`field status: enum(draft, published, out_of_print)` stores the value as
text (`"out_of_print"`) and gives the model a Rust enum named after the
model and field, `BookStatus`, with a variant per value. A new record
starts with the `default:` value, or the first one listed. In `.glacier`
you can assign an atom, and each value has a predicate:
```glacier
book = insert(Book, title: "Dune")
assert book.status == BookStatus::Draft
assert book.status.is_draft()
book.status = :out_of_print
book.save()!
assert Book.find(book.id)!.status.as_str() == "out_of_print"
```
Text that isn't one of the values fails validation with "is not one of
draft, published, out_of_print". The values don't become scopes on the
model: for `Book.published()`, write a [scope](querying.html#scopes), and
on a record use the enum's own `is_published()`.
### Stores
A `store` block reads typed settings out of a JSON column. In the `Member` model, `preferences` is the column
and each `field` in the block becomes a pair of methods:
```glacier
module Member do
implements Model
field name: String, required(message: "Please give your name")
field email: String, required, unique, format: "^[^@ ]+@[^@ ]+$"
field admin: bool, protected
field password: Option<String>, virtual, confirmation
field password_confirmation: Option<String>, virtual
field preferences: Option<JsonValue>
has_many :loans
# The books on loan, by way of each loan's `book_id`.
has_many :borrowed_books, Book, through: :loans
# A member who leaves stays on file, for their loans.
soft_deletes
store :preferences do
field language: String, default: "en"
field newsletter: bool
field nickname: Option<String>
end
# The sign-up form: a name and an email, nothing else.
changeset signup do
cast :name, :email
validate :name, length(max: 50)
end
# Staff can change a member's name and make them an admin, which
# `protected` keeps out of every other form.
changeset staff_edit do
cast :name, :admin
end
before_validation do
self.email = self.email.trim().to_lowercase()
end
validate do
if self.password.as_ref().is_some_and(fn p -> p.len() < 8 end) do
errors.add("password", "is too short (minimum is 8 characters)")
end
end
end
```
```glacier
member = insert(Member)
assert member.language() == "en"
refute member.newsletter()
member.set_language("cy".to_owned())
member.set_newsletter(true)
member.save()!
member = Member.find(member.id)!
assert member.language() == "cy"
assert member.newsletter()
assert member.nickname() == None
```
A setting without a `default:` reads as its type's default (`false`, `0`,
an empty string), or `None` for an `Option`.
### What a Model Compiles To
`build.rs` compiles each `.glacier` file into the hidden `.glacier/`
folder before the Rust compiler sees it, keeping every line on the same
line number so that errors point at your source. The `Author` model above
compiles to:
```rust title=.glacier/models/author.rs
#![allow(unused_mut)] #[allow(unused_imports)] use glacier::prelude::*; #[allow(unused_imports)] use super::*; #[glacier::model(callbacks)] pub struct Author { // @generated by glacier from author.glacier; edit that file instead
#[validate(required)] pub name: String,
pub bio: Option<String>,
#[glacier(has_many, order = "title")] pub books: ::glacier::db::HasMany<Book>,
#[glacier(has_many, foreign_key = "translator_id")] pub translations: ::glacier::db::HasMany<Book>,
} impl Author { pub fn display_name(&self) -> String {
format!("{} ({} books)", self.name, self.books.len())
}
} impl ::glacier::Callbacks for Author { async fn before_destroy(&mut self) -> ::glacier::Result<()> { Ok({
Book::filter(|__r| __r.author_id.eq_field(self.id)).delete_all().await?;
}) }
}
```
You never edit this file, but it explains what you can do with a model:
it is a plain Rust struct with public fields, so `author.name` is a
`String` and the compiler checks every use of it. The
[Glacier Language](glacier_language.html) guide covers `.glacier` itself.
## Creating Records (updated)
`Model.new(…)` builds a record in memory, taking fields as keywords.
`save()` validates it and inserts it, returning `true`, or `false` if it
isn't valid:
```glacier
author = Author.new(name: "Ursula K. Le Guin")
assert author.is_new()
assert author.save()!
assert author.is_persisted()
assert author.id > 0
```
The `!` waits for the database, and fails the action (or test) on a
database error. Fields can also be assigned one at a time:
```glacier
author = Author.new()
author.name = "Octavia E. Butler"
author.bio = "American science fiction author"
author.save()!
```
`Model.build(params)` builds a record from request parameters instead,
casting text to each field's type, which is how a controller usually
starts. The [Controllers](controllers.html) guide shows it in a `create`
action.
`Model.create(params)` validates and inserts in one step, through a
changeset. It returns either `Saved(record)` or `Invalid(changeset)`,
and the changeset carries the errors and the submitted values:
```glacier
author = Author.create(params_for(Author, name: "N. K. Jemisin"))!.unwrap()
assert author.name == "N. K. Jemisin"
changeset = Author.create(params_for(Author, name: ""))!.unwrap_invalid()
assert changeset.errors_on("name") == ["can't be blank"]
```
Given fields as keywords, `create` sets them on a new record and saves
it, returning `Saved` or `Invalid` the same way:
```glacier
author = Author.create(name: "Ursula K. Le Guin")!.unwrap()
assert author.id > 0
changeset = Author.create(name: "")!.unwrap_invalid()
assert changeset.errors_on("name") == ["can't be blank"]
```
`create` runs the model's callbacks and its `validate do` checks, as
`save()` does and as Rails' `create` does: `Book.create(…)` runs `Book`'s
`before_save`, which fills in its `slug`. A virtual field in the params,
such as a `password`, reaches the callbacks too. `changeset.insert()` is
the write without them, for the rare case that wants it.
```glacier
# before_save fills in the slug, as save() does.
book = Book.create(params_for(Book, title: "Dune", author_id: author.id, section_id: section.id))!.unwrap()
assert book.slug == "dune"
```
## Reading Records
`find` loads a record by its id, and `find_by` loads the first record
matching some fields, or `None`:
```glacier
found = Author.find(author.id)!
assert found.name == "Frank Herbert"
missing = Author.find_by(name: "Nobody")!
assert missing.is_none()
```
A missing id is an error with a `404 Not Found` status, so in a controller
action `Book.find(id)!` turns into a "not found" response on its own:
```glacier
error = Author.find(999).await.unwrap_err()
assert error.status() == StatusCode::NOT_FOUND
```
`find_or_create_by` returns the first record matching some fields, or
creates one with them, and `find_or_initialize_by` builds one without
saving it. The [Querying](querying.html) guide covers these, `where`,
`order`, scopes and the rest.
## Updating Records
Change fields and call `save()` again, or pass the changes to
`update(…)`, which assigns them and saves. Both return whether the record
was valid. Only the columns that changed are written:
```glacier
assert book.update(pages: 896, price: Decimal.from(10))!
assert Book.find(book.id)!.pages == 896
```
`update` also takes request params (`book.update(params)!`), as
`build` does. For a model with a `has_many … nested` association, as
`Book`'s `reviews` is, `update` also creates, updates and destroys the
associated records from the params, saving them together with the record
(see [Associations](associations.html#nested-attributes)). A saved record
also has `reload()`, which reads its row again.
### Dirty Tracking
A record remembers the values it was loaded with. `is_changed()` says
whether anything has changed since, `changed("column")` whether one
column has, and `was("column")` gives the value as loaded:
```glacier
book = Book.find_by(title: "Dune")!.unwrap()
refute book.is_changed()
book.title = "Dune Messiah"
assert book.changed("title")
refute book.changed("pages")
assert book.was("title").unwrap().to_text() == "Dune"
book.save()!
refute book.is_changed()
```
`changes()` lists every changed column with its new value.
## Deleting Records
`destroy()` deletes a record's row, running its `before_destroy` and
`after_destroy` callbacks. Here the `Author` model's `before_destroy`
deletes the author's books first:
```glacier
author.destroy()!
assert Author.count()! == 0
assert Book.count()! == 0
```
`destroy()` and `delete()` take the record by value, so it can't be used
afterwards. `delete()` deletes the row without running callbacks. To
delete many rows
at once, use `delete_all` on a query, as the [Querying](querying.html)
guide shows.
A `has_many` association can remove its records itself when the owner is
destroyed: `dependent: :destroy` destroys each one with its own
callbacks, and `dependent: :delete_all` deletes them in one statement,
as `Author`'s callback does by hand. The
[Associations](associations.html) guide has the details. A foreign key
with `on_delete_cascade()` in a migration has the database delete them
instead.
### Soft Deletes (new)
A model with `soft_deletes` keeps a deleted record's row, setting its
`deleted_at` instead, as Laravel's `SoftDeletes` does. A library member
who leaves stays on file, for their loans:
```glacier
# A member who leaves stays on file, for their loans.
soft_deletes
```
`destroy()`, `delete()` and `delete_all()` then set `deleted_at`, and
every query leaves such rows out: `all`, `where`, `find` (`404`), counts,
and the associations that load them. `Member.with_trashed()` reads
them too and `Member.only_trashed()` only them; `record.is_trashed()`
says which a record is, `record.restore()` brings it back, and
`record.force_delete()` (or a query's `force_delete_all()`) deletes it
for good. Destroying a soft-deleted record leaves its `dependent:`
children, so that restoring it brings them back with it:
```glacier
test "a member who leaves stays on file" do
member = insert(Member, name: "Ada", email: "ada@example.com")
member.clone().destroy()!
assert Member.count()! == 0
assert Member.find_by(id: member.id)!.is_none()
gone = Member.only_trashed().first()!.unwrap()
assert gone.is_trashed()
gone.restore()!
assert Member.count()! == 1
end
```
The column comes from a migration's `soft_deletes`, in `create_table` or
`change_table`:
```glacier
migration do
change_table :members do
soft_deletes
end
end
```
A `unique` field still counts soft-deleted rows, so a member who left
keeps their email address until they're force-deleted.
## Validations
Validations run every time a record is saved, inserted or updated. When
any fail, `save()` returns `false`, nothing is written, and the record
keeps its errors. `errors_on("column")` lists one column's messages, and
`full_messages()` gives every error with its column name in front,
humanised for people to read (`Title can't be blank`):
```glacier
book = Book.new(isbn: "123", pages: 96, author_id: author.id, section_id: section.id)
refute book.save()!
assert book.errors_on("title") == ["can't be blank"]
assert book.errors_on("isbn") == ["is too short (minimum is 13 characters)"]
assert book.full_messages() == ["Title can't be blank", "Isbn is too short (minimum is 13 characters)"]
```
`errors()` returns the same errors as `(column, message)` pairs, and
`errors_json()` as a JSON object of each column's messages
(`{"title": ["can't be blank"]}`).
A custom message replaces the standard one. The full message still
starts with the column name ("Name Please give your name"):
```glacier
member = Member.new(email: "reader@example.com")
refute member.save()!
assert member.errors_on("name") == ["Please give your name"]
```
### Confirmation and Acceptance (new)
`confirmation` on a field checks it against `<field>_confirmation`, as
Rails' does, when the confirmation is given: the second password box on
a sign-up form. `acceptance` is for a terms checkbox: when it's sent, it
must be ticked (`true`, `1`, `yes` or `on`), or the record gets "must be
accepted". Both suit `virtual` fields, which are never stored:
```glacier
field password: Option<String>, virtual, confirmation
field password_confirmation: Option<String>, virtual
```
```glacier
test "a password must match its confirmation" do
member = Member.new(name: "Ada", email: "ada@example.com")
member.password = Some("correct horse".to_owned())
member.password_confirmation = Some("wrong horse".to_owned())
refute member.save()!
assert member.errors_on("password_confirmation") == ["doesn't match Password"]
end
```
### Uniqueness
`unique` checks with a query before writing, so it works without a
unique index:
```glacier
book = Book.build(params_for(Book, isbn: "9780441013593"))
refute book.save()!
assert book.errors_on("isbn") == ["has already been taken"]
```
Two requests can still race past the check, so add a unique index in the
migration as well. If the index rejects a write, the save returns `false`
with "has already been taken" on the column instead of failing with a
database error. `unique(scope: …)` only compares rows that share the
scope's columns, so two authors may each have a book called "Dune":
```glacier
other_author = insert(Author, name: "Someone Else")
same_title = Book.build(params_for(Book, title: "Dune", isbn: "9780000000001", author_id: other_author.id))
assert same_title.save()!
again = Book.build(params_for(Book, title: "Dune", isbn: "9780000000002", author_id: first.author_id))
refute again.save()!
assert again.errors_on("title") == ["has already been taken"]
```
### Format, Inclusion and Numbers
`format:` checks text against a regular expression, which matches
anywhere in the text unless it's anchored with `^` and `$`, as the
`Member` model's `email` is. `numericality` checks that a value is a
number within any bounds given, as `Book`'s `pages` must be more than 0:
```glacier
member = Member.new(name: "Ada", email: "not an address")
refute member.save()!
assert member.errors_on("email") == ["is invalid"]
book = Book.build(params_for(Book, pages: "0"))
refute book.save()!
assert book.errors_on("pages") == ["must be greater than 0"]
```
The messages are "is invalid" for `format:`, "is not included in the
list" for `inclusion:`, and for `numericality` "is not a number", "must
be an integer", or the bound that failed ("must be less than or equal to
100"). A `None` passes all three, so combine them with `required` when
a value must be given.
### Input That Doesn't Fit
When a record is built from params, text that can't be cast to a field's
type ("lots" for an `i32`) becomes an error on that field. The record
keeps the text, so `text("column")` can put it back in the form:
```glacier
book = Book.build(params_for(Book, pages: "lots"))
refute book.save()!
assert book.errors_on("pages") == ["is not a number"]
assert book.text("pages") == "lots"
```
### Protected Fields
A `protected` field is never set from params, so a request can't make
someone an admin. Your code can still set it directly:
```glacier
member = Member.build(params_for(Member, admin: "true"))
member.save()!
refute member.admin
member.admin = true
member.save()!
assert Member.find(member.id)!.admin
```
The `id` and timestamps are never set from params either.
### Hidden Fields
A `hidden` field is never written to JSON: not by `json user`, not by a
serializer's association without a serializer of its own, and not in
the OpenAPI schema. A secret the app keeps for itself, such as an API
key for another service, belongs there:
```glacier
field api_key: Option<String>, hidden
```
Some fields are hidden without saying so: `encrypted` ones, and any
whose name ends in `_digest`, so `password_digest` and `token_digest`
never leave the app, as Laravel's `$hidden` keeps them out. A
serializer can still write a hidden field by naming it, as it can
anything.
### Encrypted Fields
An `encrypted` field is stored encrypted and read back as plain text, as
Rails' `encrypts` does, so a leaked database or backup doesn't give away
what's in it. The `accounts` app keeps its users' two-factor secrets so:
```glacier
field otp_secret: Option<String>, protected, encrypted
```
```glacier
test "keeps the secret encrypted in the database" do
user = insert(User, email_address: "user@example.com")
secret = glacier::auth::totp::new_secret()
user.enable_two_factor(&secret, &code_for(&secret))!
stored = Db.sql("SELECT otp_secret FROM users").values::<String>()!
assert stored[0].starts_with("gl1:")
refute stored[0].contains(&secret)
assert User.find(user.id)!.otp_secret == Some(secret)
end
```
It works for `String` and `Option<String>` fields, with AES-256-GCM. The
key comes from the config: `encryption.key`, or the app's
`secret_key_base` when that isn't set. To change the key, move the old
one to `encryption.previous_keys`; values encrypted with it are still
read, and are encrypted with the new key when next saved changed:
```toml
[encryption]
key = "…" # GLACIER__ENCRYPTION__KEY
previous_keys = ["…"]
```
Changing `secret_key_base` changes the key too, when `encryption.key`
isn't set, so set `encryption.key` in production.
Each value is encrypted with a new random nonce, so the same text is
stored differently every time. That means an encrypted field can't be
searched: `User.where(otp_secret: …)` finds nothing.
A field that must be found, such as an email address someone signs in
with, can be `encrypted(deterministic)` instead, as Rails'
`deterministic: true`:
```glacier
field email_address: String, encrypted(deterministic), unique
```
The same text is then stored the same way, so `where`, `find_by` and
`unique` work on it: Glacier encrypts the value a query compares the
field with, with the current key and any previous ones, so rows written
before a key changed are still found. The price is that the database
shows which rows hold the same value, so keep it for what needs
finding. A value stored
before the field was encrypted is read as it is, so a field can become
`encrypted` without a data migration. So is a value a Rails or Laravel
app encrypted, given its keys (see
[Existing Rails Apps](authentication.html#encrypted-columns)).
### Custom Validations
A `validate do … end` block holds a model's own checks. `errors.add`
records an error, and any error stops the save. This is also the place to
check a `virtual` field, which can't take validation options because it has
no column:
```glacier
member = Member.new(name: "Ada", email: "ada@example.com", password: "short")
refute member.save()!
assert member.errors_on("password") == ["is too short (minimum is 8 characters)"]
member.password = "long enough"
assert member.save()!
```
NOTE: Glacier's built-in validations are `required`, `unique`, `length`,
`format:`, `inclusion:` and `numericality`, plus `with:` for a check of
your own on one field. There's no built-in check that a confirmation
field matches or that a box was ticked; write those in a `validate`
block or a `with:` function.
### Named Changesets (updated)
Different forms often need different fields. A visitor signing up gives
a name and an email, while staff can also make someone an admin. A
`changeset` block names one such form. `cast` lists the fields it takes
from params, and `validate` adds rules of its own, written as a field's
options:
```glacier
# The sign-up form: a name and an email, nothing else.
changeset signup do
cast :name, :email
validate :name, length(max: 50)
end
# Staff can change a member's name and make them an admin, which
# `protected` keeps out of every other form.
changeset staff_edit do
cast :name, :admin
end
```
Pass params to it, and the changeset it returns to `create`, or to
`update_changeset` for an existing record:
```glacier
form = params_for(Member, name: "Ada", email: "ada@example.com", admin: "true")
member = Member.create(Member.signup(form))!.unwrap()
refute member.admin
form = params_for(Member, admin: "true")
member = member.update_changeset(Member.staff_edit(form))!.unwrap()
assert member.admin
```
Only the listed fields are cast, so the sign-up form ignores `admin`.
`staff_edit` can set it even though it's `protected`, because naming a
field in `cast` is permission to set it. The model's field validations
still run, along with the changeset's own rules:
```glacier
form = params_for(Member, name: "Ada Augusta King, Countess of Lovelace, née Byron, of London")
saved = Member.create(Member.signup(form))!
assert errors_on(saved) =~ "name is too long (maximum is 50 characters)"
```
A named changeset saves with the model's callbacks too, as `create` and
`update_changeset` do: `before_validation` runs first, then the
changeset's rules and the model's validations, then `before_save` and
the rest.
In Rust, a named changeset is a function that returns a `Changeset`, and
`glacier::validate!` takes the `#[validate(…)]` syntax:
```rust
impl Member {
pub fn signup(params: impl Into<Params>) -> Changeset<Member> {
let params = ¶ms.into();
let changeset = Member::changeset()
.cast(|m| m.name, params)
.cast(|m| m.email, params);
glacier::validate!(changeset, name, length = ..=50)
}
}
```
## Callbacks
Callbacks are blocks that run at points in a record's life. `save()` runs
them in this order:
1. `before_validation`
2. the validations, including `validate`
3. `before_save`
4. `before_create` (a new record) or `before_update` (a saved one)
5. the insert or update
6. `after_create` or `after_update`
7. `after_save`
`destroy()` runs `before_destroy` while the row still exists, deletes
it, then runs `after_destroy`. A callback is `name do … end` in the model, after its
fields. The `Member` model above tidies the email address before it's
validated, and `Book` sets its `slug` before every save:
```glacier
member = Member.new(name: "Grace", email: " Grace@Example.COM ")
member.save()!
assert member.email == "grace@example.com"
book = insert(Book, title: "The Left Hand of Darkness")
assert book.slug == "the-left-hand-of-darkness"
```
Callbacks can change fields. A change made in `before_save`, as `Book`
makes, is checked by the validations again and then written.
If a validation fails, the callbacks after it don't run and `save()`
returns `false`. If a callback returns an error, `save()` returns it. An
error in a `before_…` callback stops the write; by the time an `after_…`
callback runs, the row has already been written.
When a write happens, the record is replaced by the row the database
returned, so the `after_…` callbacks see what was stored. Anything that
isn't a column, such as a `virtual` field, is reset at that point. (Saving a
record with no changed columns writes nothing, so its virtual fields keep
their values.) Here a save that writes clears a virtual field:
```glacier
assert member.password == None
```
Use a `virtual` field's value in a callback that runs before the write,
such as `before_save`.
## Model Methods
`def` adds a method. A method that takes `self` is an instance method;
one without is called on the model (`Book.some_function()`). A method
whose body waits on the database with `!` becomes async and is called with
`!` too:
```glacier
assert book.summary() == "Dune (412 pages)"
book.publish()!
assert Book.find(book.id)!.status.is_published()
```
Parameters and return types are Rust types. A parameter can be a
closure (`keep: impl Fn(&Book) -> bool`), and one taken by value rather
than by reference can be changed in the body.
`defp` makes a private method. See [Glacier Language](glacier_language.html)
for the syntax inside methods.
## Dates and Times
A `Timestamp` field holds a time in UTC. The library's `Loan` model
records when a book is due back, and when it came back:
```glacier
module Loan do
implements Model
field due_at: Timestamp
field returned_at: Option<Timestamp>
belongs_to :member
belongs_to :book, touch
# The last second of `day` on the clock of `zone`, as UTC.
def due_end_of(day: Date, zone: &str) -> Option<Timestamp> do
end_of_day = day.and_hms_opt(23, 59, 59)?
glacier::time::local_to_utc(end_of_day, zone).map(fn at -> at.and_utc() end)
end
def overdue(self) -> bool do
self.returned_at.is_none() and self.due_at < Timestamp.now()
end
def due_on_clock(self, zone: &str) -> String do
glacier::time::format_in_zone(self.due_at, zone, "%-d %b %Y, %H:%M").unwrap_or_default()
end
end
```
### The Current Time
`Timestamp.now()` is the time now, in UTC, and `Date.today()` is today's
date. Lengths of time read as they do in Rails: `30.days()`,
`2.hours()`, `90.minutes()`, and `.ago()` or `.from_now()` turns one into
a time:
```glacier
def overdue(self) -> bool do
self.returned_at.is_none() and self.due_at < Timestamp.now()
end
```
Use these rather than reading the system clock, so that tests can move
the time (see [Testing](testing.html#time-travel)).
### Timestamps from Params
When a record is built from params, a `Timestamp` field reads RFC 3339
text, which carries its own offset, and converts it to UTC:
```glacier
loan = Loan.build(params_for(Loan, due_at: "2026-10-19T17:00:00+01:00"))
assert loan.due_at.to_rfc3339() == "2026-10-19T16:00:00+00:00"
```
A date and time without an offset, separated by `T` or a space, is read
as UTC. That covers the value an HTML `datetime-local` input sends:
```glacier
loan = Loan.build(params_for(Loan, due_at: "2026-10-19T17:00"))
assert loan.due_at.to_rfc3339() == "2026-10-19T17:00:00+00:00"
```
Seconds and fractions of a second are optional, and a trailing ` UTC` is
allowed, so the text a `Timestamp` displays as
(`2026-10-19 16:00:00.250 UTC`) reads back as the same time. A timestamp
put into a form, or passed to a factory, comes back unchanged:
```glacier
due = Timestamp.now()
assert due.to_s().ends_with(" UTC")
loan = Loan.build(params_for(Loan, due_at: due))
assert loan.due_at == due
```
A `NaiveTimestamp` field reads the same forms, and a `Date` field reads a
date as an HTML `date` input sends it (`2026-10-19`). Anything else is an
error on the field, "is not a valid date and time" (or "is not a valid
date" for a `Date`), and the record keeps the text, as it does for any
[input that doesn't fit](#input-that-doesn-t-fit):
```glacier
loan = Loan.build(params_for(Loan, due_at: "next Tuesday"))
refute loan.save()!
assert loan.errors_on("due_at") == ["is not a valid date and time"]
```
### Times on a User's Clock (updated)
A `datetime-local` value is read as UTC, but the person who typed it
meant their own clock. `glacier::time::parse(text, zone)` reads a date or
time in one of the common forms on the clock of `zone`, an IANA name such
as `"Europe/London"`, and returns the time in UTC:
- a date alone (`2026-10-19`) is midnight on that clock;
- a date and time (`2026-10-19 17:00`, `2026-10-19T17:00:00.5`) is that
time on that clock;
- RFC 3339 text keeps its own offset, and an HTTP date
(`Mon, 19 Oct 2026 17:00:00 GMT`) is read as GMT.
It returns `None` for anything else, and for a date or time without an
offset when it can't place it on the clock: in a zone it doesn't know,
or at a time that never happens because the clocks go forward:
```glacier
london = "Europe/London"
midnight = glacier::time::parse("2026-10-19", london).unwrap()
assert midnight.to_rfc3339() == "2026-10-18T23:00:00+00:00"
five = glacier::time::parse("2026-10-19 17:00", london).unwrap()
assert five.to_rfc3339() == "2026-10-19T16:00:00+00:00"
with_offset = glacier::time::parse("2026-10-19T17:00:00Z", london).unwrap()
assert with_offset.to_rfc3339() == "2026-10-19T17:00:00+00:00"
assert glacier::time::parse("next Tuesday", london) == None
assert glacier::time::parse("2026-10-19 17:00", "Mars/Olympus") == None
# The clocks went from 01:00 to 02:00 on 29 March 2026.
assert glacier::time::parse("2026-03-29 01:30", london) == None
```
In a controller, read the param's text and assign what `parse` returns,
rather than letting `build` read it as UTC.
`glacier::time::local_to_utc(local, zone)` turns a `NaiveTimestamp` on
the clock of `zone` into one in UTC, for storing. A time that happens
twice, when the clocks go back, takes the earlier; a time that never
happens, when they go forward, gives `None`, as does a zone it doesn't
know. The `Loan` model's `due_end_of` uses it to find the last second of
a day on the member's clock, and `and_utc()` makes the result a
`Timestamp`:
```glacier
day = Date.from_ymd_opt(2026, 10, 19).unwrap()
due = Loan.due_end_of(day, "Europe/London").unwrap()
assert due.to_rfc3339() == "2026-10-19T22:59:59+00:00"
winter = Date.from_ymd_opt(2026, 12, 7).unwrap()
assert Loan.due_end_of(winter, "Europe/London").unwrap().to_rfc3339() == "2026-12-07T23:59:59+00:00"
```
To show a time, `glacier::time::format_in_zone(at, zone, format)` formats
a `Timestamp` on the clock of `zone` with a `strftime` pattern, and
`format_naive_in_zone` does the same for a `NaiveTimestamp` stored as
UTC. Both return `None` for a zone they don't know. `Loan`'s
`due_on_clock` uses it:
```glacier
loan = insert(Loan, due_at: "2026-10-19T22:59:59Z")
assert loan.due_on_clock("Europe/London") == "19 Oct 2026, 23:59"
assert loan.due_on_clock("America/New_York") == "19 Oct 2026, 18:59"
```
### The App's Own Clock (new)
`time_zone` in the config is the clock the app shows times on when it
has no user's own to use (UTC without one), as Rails'
`config.time_zone`:
```toml
# The clock pages show times on, when there's no user's own to use.
time_zone = "Europe/London"
```
`at.local(format)` formats a timestamp on it, `glacier::time::parse_local(text)`
reads what someone typed on it, and `glacier::time::zone()` names it:
```glacier
test "times on the app's clock" do
noon = "2026-07-01T12:00:00Z".parse::<Timestamp>().unwrap()
# London is an hour ahead of UTC in summer.
assert noon.local("%H:%M") == "13:00"
assert glacier::time::parse_local("2026-07-01 13:00") == Some(noon)
assert glacier::time::zone() == "Europe/London"
end
```
Times are still stored in UTC. An app that knows each user's zone
passes it to the functions above instead. An unknown name stops the app
at start-up.
## Models in Another Database
Some data belongs in a database of its own: analytics that would slow
the main database down, or an existing system's tables. Name the database
in the config:
```toml
[databases.analytics]
url = "sqlite://db/analytics_development.sqlite3"
```
In production, set its URL through the environment, as
`GLACIER__DATABASES__ANALYTICS__URL`. A `replica_url` gives it a replica,
as for the main database (see
[Read Replicas](querying.html#read-replicas)).
A model names its database after its name. This one lives with the rest
of the analytics code, in `src/analytics/`: a model can go in any folder,
as `implements Model` is what makes it one, and every controller,
serializer and test sees it as they see the models in `src/models/`. So
can a controller, job, mailer, client or middleware: each is what it
implements, wherever it is:
```glacier
# A book's page was seen. Views live in the analytics database, apart from
# the catalogue, so counting them never slows lending down.
module BookView do
implements Model, database: :analytics
field book_id: i64
end
```
Everything else about the model is as usual. Its queries and saves go to
the analytics database, connected when a request first uses it, and
everything else stays in the main one:
```glacier
test "views are counted in the analytics database" do
book = insert(Book, title: "Dune")
BookView.new(book_id: book.id).save()!
BookView.new(book_id: book.id).save()!
assert BookView.where(book_id: book.id).count()! == 2
assert Book.count()! == 1
end
```
The database's migrations live in `db/analytics/migrations/`, beside its
schema in `db/analytics/schema.sql`, laid out as `db/migrations/` is.
`glacier generate migration create_book_views --database analytics`
writes one there, and the app runs them, as
`analytics_migrations::all()`, with no line of its own:
```glacier
create_table :book_views do
# Another database: an id, not a foreign key.
integer :book_id, null: false
timestamps
end
add_index :book_views, :book_id
```
`glacier db migrate` runs the main database's migrations, then each named
database's. `glacier db status` lists them all, and
`GLACIER_DATABASE=analytics glacier db rollback` rolls back the named one.
Tests migrate a fresh named database, as they do the main one.
A few things don't cross between databases. A `belongs_to` or `has_many`
can't join a model in another database, so `BookView` keeps a plain
`book_id`. A transaction covers one database, so `transaction do … end`
doesn't include `BookView`'s writes. In Rust, `db::transaction_in(Some("analytics"), …)`
runs a transaction on the analytics database.
## Transactions
`transaction do … end` runs its body in one database transaction (see
[The Glacier Language](glacier_language.html)). Every save inside it
commits together, or none do.
### Multi-step Transactions
When a transaction has several steps and the caller needs to know which
one failed, build a `Multi`, as Ecto's. Each step has a name, runs in
order, and can read the results of the steps before it. `insert` and
`update` take a changeset, `run` takes any async work, and `enqueue`
queues a background job that is only queued if everything commits. A
`Multi` is written in Rust for now:
```rust
use glacier::db::{Changeset, Done, Failure, Multi, MultiError};
use library::models::Loan;
/// A new member joins and borrows their first book, in one transaction.
async fn join_and_borrow(params: Params, book_id: i64) -> Result<Done, MultiError> {
Multi::new()
.insert("member", Member::signup(params))
.run("loan", move |done| async move {
let member: &Member = done.get("member").unwrap();
let due_at = 21.days().from_now();
let loan = Changeset::<Loan>::new()
.set(|l| l.member_id, member.id)
.set(|l| l.book_id, book_id)
.set(|l| l.due_at, due_at);
Loan::create(loan).await?.into_step()
})
.commit()
.await
}
```
`commit` returns the results by step name, or a `MultiError` naming the
step that failed and why. A step whose changeset was invalid fails with
its validation errors, and nothing the earlier steps wrote is kept:
```rust
match join_and_borrow(form("Ada", "ada@example.com"), dune.id).await {
Ok(done) => {
let loan: &Loan = done.get("loan").unwrap();
assert_eq!(loan.book_id, dune.id);
}
Err(err) => panic!("{err}"),
}
let long_name = "Augusta Ada King, Countess of Lovelace, née Byron, of Marylebone";
let err = join_and_borrow(form(long_name, "augusta@example.com"), dune.id)
.await
.unwrap_err();
assert_eq!(err.step, "member");
assert!(matches!(err.failure, Failure::Invalid { .. }));
assert_eq!(
err.to_string(),
"step `member` was invalid: Name is too long (maximum is 50 characters)"
);
```
In an action, `commit().await?` turns an invalid step into a 422
response carrying its errors, and any other failure into its own error.
## Models in Rust
A model can be a plain Rust file, `src/models/<name>.rs`. `models!`
finds it as it finds `.glacier` files, and `glacier generate model …
--dialect rust` writes one (declaring it with a `mod` line instead). The `.glacier` features are attributes there:
`#[glacier::model]` adds the id, timestamps and the record's state,
`#[validate(…)]` holds field validations, `#[glacier::enumeration]`
declares an enum stored as text, `#[glacier::scopes]` declares scopes,
and `impl Callbacks` holds callbacks (with `callbacks` passed to
`#[glacier::model]`):
```rust
use glacier::prelude::*;
#[glacier::model(callbacks)]
pub struct Review {
#[validate(required)]
body: Option<String>,
rating: i32,
status: ReviewStatus,
book_id: i64,
}
#[glacier::enumeration]
pub enum ReviewStatus {
Pending,
Approved,
}
#[glacier::scopes]
impl Review {
fn approved(reviews: Query<Self>) -> Query<Self> {
reviews.filter(|r| r.status.eq(ReviewStatus::Approved))
}
fn rated_at_least(reviews: Query<Self>, stars: i32) -> Query<Self> {
reviews.filter(|r| r.rating.ge(stars))
}
}
impl Callbacks for Review {
async fn before_save(&mut self) -> glacier::Result<()> {
self.rating = self.rating.clamp(1, 5);
Ok(())
}
}
```
Rust and `.glacier` models use each other freely. The library's `Book`
has `has_many :reviews`, and tests in `.glacier` use `Review` like any
other model:
```glacier
review = Review.new()
review.book_id = book.id
review.body = Some("Gripping".to_owned())
review.rating = 9
review.save()!
assert review.rating == 5
assert review.status == ReviewStatus::Pending
review.update(status: :approved)!
assert Review.approved().rated_at_least(4).count()! == 1
```
Scopes in Rust take typed column closures; the [Querying](querying.html)
guide shows the methods they can use.