Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MySQL

Toasty’s MySQL driver uses SQLx’s MySQL driver. It covers the SQL feature set Toasty exercises — row locking, native temporal types, inline enum columns, full unsigned 64-bit integers, and both fixed-precision and arbitrary-precision decimals — and integrates with Toasty’s connection pool for retry and recovery.

Enabling the driver

Add the mysql feature to Toasty in Cargo.toml:

[dependencies]
toasty = { version = "0.10", features = ["mysql"] }

Then pass a mysql:// URL to Db::builder:

let db = toasty::Db::builder()
    .models(toasty::models!(crate::*))
    .connect("mysql://user:pass@localhost:3306/mydb")
    .await?;

The URL must include a database name in the path. TLS uses rustls by default.

To select native TLS instead:

[dependencies]
toasty = { version = "0.10", default-features = false, features = ["mysql", "native-tls"] }

Connection URL options

The driver accepts SQLx MySQL connection parameters:

ParameterPurpose
ssl-mode=<mode>Set the TLS policy: disabled, preferred (default), required, verify_ca, or verify_identity. preferred falls back to plaintext if TLS is unavailable.
ssl-ca=<path-or-pem>Set a trusted CA certificate file or inline PEM.
ssl-cert=<path-or-pem>Set the client certificate for mutual TLS.
ssl-key=<path-or-pem>Set the client private key for mutual TLS.
socket=<path>Connect over a Unix socket instead of TCP.
statement-cache-capacity=<n>Set the per-connection prepared-statement cache size. Defaults to 100; set to 0 to disable caching.
charset=<name>Set the connection character set. Defaults to utf8mb4.
collation=<name>Set the connection collation.
timezone=<value>Set the session time zone, such as %2B00:00 for +00:00.

SQLx ignores unrecognized connection parameters. Use the option names listed above.

.connect("mysql://app:secret@db.internal/store\
          ?ssl-mode=verify_identity&ssl-ca=/etc/ssl/private-ca.pem")

Type mapping

Toasty maps Rust types to MySQL columns as follows. MySQL has a few quirks worth knowing about; the notes below the table call them out.

Rust typeMySQL column type
boolBOOLEAN (a TINYINT(1) alias)
i8TINYINT
i16SMALLINT
i32INTEGER
i64BIGINT
u8TINYINT UNSIGNED
u16SMALLINT UNSIGNED
u32INTEGER UNSIGNED
u64BIGINT UNSIGNED
f32FLOAT
f64DOUBLE
StringVARCHAR(191) by default; override with #[column(type = varchar(N))]
Vec<u8>BLOB
uuid::UuidVARCHAR(36)
rust_decimal::Decimal (feature)DECIMAL(p, s) — precision and scale required
bigdecimal::BigDecimal (feature)DECIMAL(p, s) — precision and scale required
jiff::Timestamp (feature)DATETIME(6), stored in UTC
jiff::civil::Date (feature)DATE
jiff::civil::Time (feature)TIME(6)
jiff::civil::DateTime (feature)DATETIME(6)
toasty::stmt::IpCidr (feature)VARCHAR(43)
toasty::stmt::IpInet (feature)VARCHAR(43)
toasty::stmt::MacAddr6 (feature)VARCHAR(17)
toasty::stmt::MacAddr8 (feature)VARCHAR(23)
Vec<T> (T scalar)JSON
Embedded enumInline ENUM('a', 'b', ...) column

Notes on the type mapping

VARCHAR(191) is the default string type. MySQL’s row format caps the total row size at 65,535 bytes, and utf8mb4 consumes up to four bytes per character. An indexed VARCHAR column has a per-index prefix limit of 767 bytes on older InnoDB row formats — 191 characters fits inside that limit with utf8mb4, so the default lets you index a string field without configuring anything. Override with #[column(type = varchar(N))] for N up to 65,535 (see Field Options). The schema builder rejects larger values.

Full unsigned 64-bit range. MySQL has native unsigned integer types, so u64 rides BIGINT UNSIGNED and can hold the full 0..=2⁶⁴−1 range. This is the only Toasty backend where u64 is not capped at i64::MAX.

UUIDs go in VARCHAR(36). MySQL has no native UUID type. Toasty stores UUIDs as their hyphenated text form. A 16-byte BINARY(16) column would pack tighter, but VARCHAR(36) is easier to inspect from a SQL prompt.

jiff::Timestamp maps to DATETIME(6), not TIMESTAMP. MySQL’s TIMESTAMP only spans 1970-01-01 to 2038-01-19. Toasty uses DATETIME(6) instead and converts to and from UTC at the driver layer, so values round-trip as UTC instants without being bound to the 2038 cutoff.

Decimal requires fixed precision and scale. Unlike PostgreSQL’s NUMERIC, MySQL’s DECIMAL always has a declared precision and scale; there is no arbitrary-precision mode. Set them with #[column(type = decimal(p, s))] when declaring the field.

BigDecimal works natively on MySQL. Toasty rides DECIMAL(p, s) for bigdecimal::BigDecimal here. PostgreSQL falls back to text for BigDecimal; MySQL is currently the only backend that exchanges it as a native decimal value over the wire.

Vec<scalar> goes in a JSON column. Toasty serializes the list to a JSON array at bind time and parses it back on read. Array predicates (contains, is_superset, intersects, len, is_empty) lower to MySQL’s JSON_CONTAINS, JSON_LENGTH, and related functions.

jiff::Zoned stores as TEXT. MySQL has no column type that carries an IANA zone name alongside an instant, so zoned values round-trip through text.

Network addresses use bounded text columns. MySQL has no native network address types. Toasty stores the canonical text form and sizes each VARCHAR for the longest value of that address family.

Behavior specific to MySQL

Toasty enables these features automatically when the driver is MySQL. No configuration is required.

Full unsigned 64-bit integers. Values up to u64::MAX round-trip through BIGINT UNSIGNED without truncation. SQLite and PostgreSQL cap unsigned types at i64::MAX.

Inline enum columns. An embed-tagged Rust enum maps to a column declared ENUM('variant_a', 'variant_b', ...). There is no separate named type to maintain; adding a variant emits an ALTER TABLE ... MODIFY COLUMN against the same column.

Both Decimal and BigDecimal are native. Enable the rust_decimal feature for rust_decimal::Decimal, the bigdecimal feature for bigdecimal::BigDecimal, or both. Each maps to DECIMAL(p, s) with declared precision and scale.

Row-level locking. Generated transactions can use SELECT ... FOR UPDATE to lock rows for the duration of a transaction.

Backward pagination. .paginate(per_page).prev(&db) walks backwards from a page cursor.

Case-sensitive prefix match. The .starts_with() filter lowers to BINARY col LIKE 'prefix%'. Casting the column to BINARY forces a byte comparison, so the match is case-sensitive regardless of the column’s collation.

A few things that exist on PostgreSQL are absent here:

No targeted upsert. MySQL’s ON DUPLICATE KEY UPDATE reacts to any primary key or unique-index conflict; it cannot restrict the update to the constraint named by upsert_by_*. Toasty returns unsupported_feature instead of updating a row selected by a different constraint.

No ILIKE. MySQL has no ILIKE operator, so .ilike() is rejected with an unsupported_feature error. MySQL’s LIKE case sensitivity is set by the column’s collation: utf8mb4_unicode_ci and other _ci collations match case-insensitively, while binary and _bin collations match case-sensitively. Pick the collation that matches the semantics you want when declaring the column, then use .like().

No native RETURNING from INSERT or UPDATE. For a single-row insert with one auto-increment result, Toasty retrieves the exact generated ID with LAST_INSERT_ID() on the same connection. This lets Model::create() return a populated model.

MySQL cannot return every generated ID from a multi-row insert. Toasty does not assume that later IDs are consecutive: a bulk insert that needs database-generated values is rejected with unsupported_feature before the statement is sent. Bulk inserts remain available when all returned values are known before execution, such as models with caller-generated or client-generated UUID keys. Otherwise, insert each record separately or use a backend with mutation RETURNING support.

For updates that need returned columns, Toasty performs a follow-up SELECT. That read is not atomic with the update relative to concurrent writers.

No CTE-driven updates. MySQL does not allow UPDATE inside a common table expression. The query planner avoids generating those. Most update patterns do not need them; the engine routes complex multi-statement updates differently on this backend.

Migrations

The migration generator emits DDL that MySQL can apply inside a single transaction, and apply_migration wraps each migration in START TRANSACTION / COMMIT. A failure rolls back the migration and the bookkeeping row in __toasty_migrations together.

Two MySQL-specific behaviors worth knowing:

Column changes are atomic. MySQL’s ALTER TABLE ... MODIFY COLUMN rewrites name, type, nullability, and default in a single statement. The migration generator takes advantage of this — a property change emits one statement, not several. PostgreSQL needs one statement per property; MySQL does not.

Enum types live inline on the column. Adding a variant emits an ALTER TABLE ... MODIFY COLUMN against the column whose type is the enum. There is no separate CREATE TYPE step.

The migration tooling does not yet manage zero-downtime online migrations on MySQL (pt-online-schema-change-style copy and swap, gh-ost integration, etc.); migrations assume exclusive access to the schema for their duration.

Errors and the connection pool

The driver classifies SQLx MySQL errors into Toasty’s typed error variants so the pool and caller can react sensibly.

MySQL error or conditionToasty error
Error 1213 (ER_LOCK_DEADLOCK)Error::SerializationFailure — retryable. InnoDB rolled back the transaction to break a deadlock; retry the unit of work.
Error 1792 (ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION)Error::ReadOnlyTransaction — the connection is read-only.
Other server errors with a SQLSTATEError::DriverOperationFailed
Socket / protocol errors (closed connection, pool disconnected)Error::ConnectionLost

A ConnectionLost error tells the pool to evict the failed connection and flips the connection’s internal validity flag so the pool does not hand it back out. The next acquire pings idle connections, drops the ones that fail, and opens a fresh slot if needed — so a backend restart typically costs one failed user query rather than one per pooled connection. See Database Setup for the pool knobs (max_pool_size, pool_pre_ping, pool_health_check_interval, …) and what they do.

SQLx caches prepared statements per connection (up to 100 by default, tunable via the statement-cache-capacity URL parameter). The cache is bound to the connection and is dropped when the connection is evicted, so it does not cause stale-state issues after a backend restart.