navree
Documentation

Schema builder

Tables described in Rust and written as the right SQL for PostgreSQL, MySQL, MariaDB and SQLite: creating, altering, renaming and dropping tables, table options, SQLite rebuilds, reading the schema, previews without a database, and configuration.

On this page

The schema builder (navree::schema) describes tables in Rust and writes the SQL each database needs: PostgreSQL (with TimescaleDB), MySQL, MariaDB and SQLite. Migrations use it through the Schema they receive; anywhere else, build one on a connection with Schema::new(db.conn()).

Rust

create takes a closure that describes the table on a Blueprint (t); table alters one the same way. Before any SQL runs, the blueprint is checked: duplicate columns, two primary keys, a decimal scale larger than its precision, a modifier that doesn't apply to its column's type, a type the database doesn't have. Each mistake fails with a message saying how to fix it, so a migration never half-runs on a typo. Then every statement runs in order on the schema's connection, inside a migration its transaction.

navree::schema::prelude brings Schema, SchemaResult, Blueprint, raw, ReferentialAction and the TimescaleDB types (Hypertable, Compression, ContinuousAggregate, RefreshPolicy, KeyStrategy).

Tables

MethodDoes
schema.create(table, |t| ..)Creates a table.
schema.table(table, |t| ..)Alters a table: adds, changes, renames and drops columns, indexes and foreign keys.
schema.rename(from, to)Renames a table.
schema.drop(table), schema.drop_if_exists(table)Drops a table.
schema.drop_columns(table, columns)Drops columns.
schema.create_hypertable(table, time_column, |t| ..)Creates a time-series table.
schema.statement(sql)Runs raw SQL, recorded like the builder's own statements.

The columns and indexes and foreign keys pages list what goes in the closure. Inside create, these set options of the table itself:

Blueprint methodDoes
t.if_not_exists()CREATE TABLE IF NOT EXISTS.
t.temporary()A temporary table.
t.comment("...")A table comment (PostgreSQL and MySQL).
t.engine("InnoDB"), t.innodb()The storage engine (MySQL).
t.charset("utf8mb4"), t.collation("utf8mb4_bin")The default character set and collation (MySQL).

MySQL tables get DEFAULT CHARACTER SET utf8mb4 unless the configuration says otherwise (below).

Altering tables

Rust

In table, every call is a command, run in the order you wrote them: t.string("slug") followed by t.unique(["slug"]) adds the column, then the index. Changing columns and dropping indexes have the details. t.drop_table(), t.drop_table_if_exists() and t.rename(to) act on the table itself.

SQLite

SQLite's ALTER TABLE adds, renames and drops plain columns. For everything else (changing a column, adding or dropping a primary key or a foreign key, adding a column it can't add in place, dropping one that a key, a constraint or an index uses), the builder rebuilds the table, as SQLite documents: it reads the table as it is, creates the new one as __temp__<table>, copies the rows, drops the old table, renames the new one, and recreates the indexes and triggers. Collations, checks, generated columns, partial indexes and AUTOINCREMENT survive. Several changes in one table call share one rebuild.

Dropping the old table must not run the ON DELETE actions of the tables that reference it, so the migrator runs SQLite migrations on a connection of their own with foreign keys off, and checks them (PRAGMA foreign_key_check) before each commit. Outside a migration, rebuilding a table other tables reference, on a connection with foreign keys on, fails with that explanation instead of deleting rows by cascade. Adding a NOT NULL column without a default to a table that has rows fails with how to fix it.

Reading the schema

A schema reads the database's catalogs:

MethodReturns
has_table(table), has_view(view)Whether it exists.
has_column(table, column), has_columns(table, [..])Whether the table has them.
tables(), table_listing()The tables of the current schema: name, schema, size, comment, engine, collation (or only the names).
views()The views.
columns(table), column_listing(table)The columns: type, collation, nullable, default, auto-increment, comment, generation (or only the names).
column_type(table, column, full)One column's type name, or its full definition.
indexes(table), index_listing(table)The indexes: columns, method, uniqueness.
has_index(table, name_or_columns, kind)Whether an index exists, by name or by columns.
foreign_keys(table), has_foreign_key(table, ..)The foreign keys, with their columns and actions.
Rust

when_table_has_column and when_table_doesnt_have_column alter a table only when it has (or lacks) a column. On TimescaleDB, is_hypertable(table) and time_dimension(table) read how a table is partitioned.

Emptying a database

drop_all_tables(), drop_all_views() and drop_all_types() (PostgreSQL) empty a database, as migrate:fresh does. disable_foreign_key_constraints() and enable_foreign_key_constraints() switch foreign-key checks off and on for the connection (on PostgreSQL, disable defers them to the end of the transaction), and ensure_extension(name) creates a PostgreSQL extension when it is missing.

Previews

Schema::offline(target) records the SQL for a database without connecting to one, for tests and for checking what a migration writes for another database:

Rust

Targets name the database and its version, which the builder checks features against: Target::postgres(17).with_timescaledb((2, 17)), Target::mysql((8, 4)), Target::mariadb((11, 4)), Target::sqlite((3, 46)). Virtual generated columns need PostgreSQL 18, NULLS NOT DISTINCT PostgreSQL 15, RENAME COLUMN MySQL 8.0.3 or MariaDB 10.5.2, expression defaults MySQL 8.0.13, and vector columns MySQL 9 or MariaDB 11.7: an older target fails with that requirement instead of failing SQL. A connected schema detects its target from the server once (version, installed and available extensions).

An offline schema can't plan what needs the live table: a SQLite rebuild, or converting a table into a hypertable, fails with "needs a database connection". Schema::new(conn).pretending() reads the real database and records what it would change instead of changing it, which is what migrate --pretend does. Both kinds give the statements with schema.statements() and what the database couldn't do as asked with schema.notices().

Configuration

The schema section configures the builder, and every migration's schema:

config/app.toml
KeyDefaultWhat it does
default_string_length255The length of string columns without .length(n).
default_time_precision6The fractional-second digits of time columns without .precision(p). Unset, each database's default applies (whole seconds on MySQL).
morph_keyintThe _id column type of morphs("subject"): int, uuid or ulid.
timescaledbautoauto uses TimescaleDB when the database has it and falls back to regular tables with a notice; require makes that fallback an error on PostgreSQL; off never uses it.
create_extensionstrueLets the builder run CREATE EXTENSION IF NOT EXISTS timescaledb when the extension is available but not installed.
hypertable_keysextendA hypertable's primary key that lacks the time column is widened (extend) or refused (reject).
mysql_charset, mysql_collation, mysql_engineutf8mb4, the server's, the server'sMySQL table defaults.
dump_pathdatabase/schemaWhere schema:dump writes and migrate looks for the dump.

Like every section, these come from the environment too: APP_SCHEMA__TIMESCALEDB=require in production makes a migration stop on a server without TimescaleDB rather than create regular tables.