[0.4.3] -- 2026-09-08
Work from the reality check and release hardening (beads labeled reality-check-2026-09 and bd-jeof).
This release brings all 12 workspace crates to 0.4.3 in unified lockstep, resolving crates.io index divergence.
Added
- ORM user guide (
docs/guide/) and facade modulesqlmodel::guide. An 11-chapter,
comprehensive user guide covering models & field attributes, query building (select!,insert!,
update!,delete!,insert_many!, upserts, RETURNING), sessions & unit of work (identity map,
dirty tracking,Session::with_retry), relationships (Related<T>,Lazy<T>,RelatedMany<T>,
EagerLoader, batch loaders, N+1 detection), model inheritance (STI, JTI, CTI, polymorphic queries),
schema & migrations (SchemaBuilder, introspection, diffing,MigrationRunner), database drivers
(C-SQLite, FrankenSQLite MVCC, PostgreSQL wire driver, MySQL binary protocol), connection pooling
(Pool, sizing,ReplicaPool,close_and_drain), error taxonomy (Outcome<T, E>,is_retryable,
retry_transaction), cancellation & structured concurrency (asupersyncCx, budgets, transaction
guards), and testing patterns (in-memory SQLite, isolation,LabRuntime,CancelAtsweeps).
Every chapter ends with "Differences from Python SQLModel". All snippets are verified via doctests
rendered in rustdoc assqlmodel::guide, and the prior documentation limitation is retired from
the README and protected by the doc-drift guard. Session::with_retry. Runs a closure-shaped unit of work against the session and commits,
retrying the whole unit when a flush or the commit fails with a retryable error (serialization,
deadlock, busy-snapshot conflicts underTransactionMode::Concurrent). Between attempts the open
transaction is rolled back and the identity map cleared, so the closure re-adds what it needs;
backoff respects theCxbudget and a cancelledCxis never retried. Built on the new
RetryPolicy::after_failure/RetryDecision, whichretry_transactionnow shares, so any
caller can put a jittered, budget-aware retry loop around anything else.nightly-tryfeature (sqlmodelandsqlmodel-core): enables asupersync'sTry
implementation so?works onOutcome, propagating errors and cancellation alike. Off by default
because it needs a nightly toolchain and the MSRV job builds on stable.- The README is compiled. Every runnable Rust block in
README.mdis a doctest of thesqlmodel
facade (#[doc = include_str!], built with--features nightly-try); the three blocks that are
not programs (console output, attribute cheat-sheet,Exprcatalogue) say so and are marked
ignore. Rewriting the blocks as complete functions showed two things the README had claimed for
a long time that were not true: aSelect::to_sqlmethod (the real API isbuild_with_dialect,
now shown with its SQL asserted) and?onOutcome, which never compiled without the feature. - Concurrent writers through the public API.
sqlmodel_core::TransactionMode
(Default | Concurrent | Immediate | Exclusive | Deferred) andTransactionOptions, plus
Connection::supports_transaction_modeandConnection::begin_with_options(default
implementation refuses unsupported modes with the new
TransactionErrorKind::UnsupportedModeinstead of downgrading). FrankenSQLite maps
ConcurrenttoBEGIN CONCURRENT(page-level MVCC; previously reachable only via raw SQL) and
gainsbegin_concurrent_sync; C SQLite offers the three locking forms and rejectsConcurrent
with a message pointing atsqlmodel-frankensqlite; PostgreSQL and MySQL acceptConcurrentas
their native MVCC default.SessionConfig::transaction_mode/
SessionConfig::with_transaction_modemakeSessionstart its transactions in a chosen mode.
Re-exported from the facade and its prelude. retry_transactionandRetryPolicy(sqlmodel_core::retry): re-run a whole transaction
when it fails with an errorError::is_retryable()accepts (serialization failures, write
conflicts, deadlocks), with jittered exponential backoff that never sleeps past theCxbudget
deadline, never retries afterCancelled/Panicked, and reports exhaustion as
TransactionErrorKind::RetriesExhausted(Error::retries_exhausted). Uses async closures
(AsyncFnMut).crates/sqlmodel-e2e(workspace member,publish = false): an all-driver end-to-end harness
(DriverUnderTest,Scenario,run_on_every_driver, andCapturingConnection, a driver
wrapper that records every statement so scenarios can assert the SQL the ORM sent) that runs
the same ORM scenarios on
C SQLite (memory and file), FrankenSQLite, and, whenSQLMODEL_TEST_{POSTGRES,MYSQL,MARIADB}_URL
are set, PostgreSQL/MySQL/MariaDB. Scenarios: model CRUD smoke (including a table namedorder
with columnsuserandselect), everyExprfamily throughselect!(comparisons, NULL
tests, LIKE, IN, BETWEEN, boolean composition, string/numeric/conditional functions, GROUP BY
with HAVING, DISTINCT, paging, EXISTS;ILIKEis reported as skipped off PostgreSQL), type
round-trips,MigrationRunner,Session(unit of work, one-to-many, many-to-many through a link
table, lazy many-to-one, lifecycle events,merge, session-side cascades), the pool, and two OS
threads of concurrent writers withretry_transaction(no lost updates; C SQLite asserts the
UnsupportedModerefusal). Theattributesscenario is a conformance corpus for every
#[sqlmodel(...)]field attribute in one model set (primary_key+auto_incrementon an
Option<i64>,unique,nullable,columnrenames,column_comment,column_constraints,
default,index,sql_type,foreign_keywithon_delete/on_updateCASCADE and SET NULL,
skip_insert,skip_update, a composite primary key): it prints the DDL, proves the created
schema is a fixpoint of the differ, checks what introspection reports, and exercises the
runtime behavior (defaults, UNIQUE and CHECK rejections, generated ids, skipped columns,
compositeget_by_pk, cascades) on every driver. Its first run found bugs 44-49 below.
Theoperationsscenario executes every query-builder statement shape on every driver:
upserts (on_conflict_do_update,on_conflict_do_nothing, a UNIQUE conflict target, bulk
upserts), a 100-row bulk insert, INSERT/UPDATE/DELETE with RETURNING of non-key columns, eager
loading in both directions, the explicitJoinbuilder (inner and left), typed and string
subqueries (IN,NOT IN, scalar), UPDATE/DELETE with subquery predicates, and raw
projections. Its first run found bugs 50-56 below.- Eager loading now loads.
Select::eager(EagerLoader::new().include("team")).all_eager(..)
built the LEFT JOIN and fetched the related columns, then threw them away: every relationship
field came back unloaded.Model::hydrate_relationship(generated by the derive for every
Related<T>,Lazy<T>, andRelatedMany<T>field) now receives the joined rows grouped per
parent, so a to-one field is loaded (or loaded-as-Nonefor a LEFT JOIN without a match) and a
to-many field holds its children, with parents deduplicated across the fan-out. Nested
include paths are accepted but only the first level is joined. - Typed
IN (SELECT ...).Expr::in_query/Expr::not_in_querytake a subquery from
Select::into_query(now public) and render it for the enclosing statement's dialect with
renumbered parameters. - RETURNING on MySQL.
execute_returningon the insert, update, and delete builders used to
sendRETURNING *to MySQL, which has no such clause. It now re-reads the rows by primary key:
an insert re-selects the inserted row (given key or generated id); an update snapshots the
matching keys, updates, and re-reads them; a delete reads the matching rows before deleting,
each inside one transaction. Joined-table-inheritance models are unchanged. - Facade exports.
sqlmodel(and its prelude) now re-exportEagerLoader,IncludePath,
InsertBuilder,InsertManyBuilder,UpdateBuilder,DeleteBuilder,OnConflict,
SelectQuery, and theinsert_many!macro; bulk inserts and eager loading previously needed
a directsqlmodel-querydependency. - Pool proof on the server side. The e2e pool scenario now runs 4 x max acquirers as tasks
under one asupersync runtime (no OS threads): every task is served, no timeout, never more than
max connections. While max leases are held, PostgreSQL'spg_stat_activity(the pool's
sessions carry anapplication_name) and MySQL'sinformation_schema.processlistreport
exactly max sessions for the pool. Lifetime retirement, a server-side kill of an idle
connection, and the detach rule are checked through backend ids (see Fixed below). A lease
holder that panics is proven to return its connection during unwinding: the pool keeps
serving with consistent counters and still drains (documented on the crate). - MySQL parameterized statements use the binary protocol.
Connection::query/execute/
insertwith parameters and?placeholders now go throughCOM_STMT_PREPARE/
COM_STMT_EXECUTE, binding values as typed parameters instead of rendering them as literals
for the text protocol (the literal renderer was the source of bugs 24 and 25). Prepared
statements are cached per connection, keyed by SQL text, at most 64 (STATEMENT_CACHE_CAPACITY)
with the least recently used closed on eviction. Parameterless statements,$nplaceholders,
and the statement kinds MySQL refuses to prepare (ER_UNSUPPORTED_PS) keep the text protocol.
An integration test reads the server'sCom_stmt_prepare/Com_stmt_execute/
Com_stmt_closecounters: one prepare per distinct statement, one execute per call, closes on
eviction. The whole e2e crate (types, attributes, operations, session, migrations, pool) runs
MySQL on this path. - PostgreSQL prepared statements are cached per connection.
Connection::query/execute
with parameters now use named prepared statements (sqlmodel_sN) cached in a 64-entry LRU
cache (STATEMENT_CACHE_CAPACITY), keyed by(sql, param_oids). On a cache hit, theParse
step is skipped entirely andBind+Describe Portal+Execute+Syncare executed directly.
When capacity is exceeded, the least recently used statement is closed on the server viaClose(Statement).
Parameterless queries continue using the unnamed statement without caching to avoid polluting
server statement tables with DDL or utility commands. Reset commands (DISCARD ALL,RESET ALL)
and connection close clear the cache. An integration test validates cache reuse, distinct statement
isolation, OID sensitivity on type alternation, 64-entry eviction bounds, and cache invalidation. - Statement reuse stated per driver, from the servers' own counters. The e2e
session
scenario runs 20Session::getcalls on one connection and reads the server: MySQL shows one
Com_stmt_prepareand 20Com_stmt_execute(the driver's cache), PostgreSQL shows one
statement inpg_prepared_statementsand 20 executions (the driver's cache; bd-vsyd), and the
SQLite drivers keep no statement cache. The README's "Prepared statements" row and the
sqlmodel_query::cachedocs reflect this; that module caches SQL text and is not connected to any
driver. - Compile-time diagnostics that name the feature. A model with a
chrono,uuid, or
rust_decimalfield built without the matchingsqlmodelfeature now fails with
"chrono::NaiveDateTimecannot be read from a database row" plus a note naming the feature to
enable (#[diagnostic::on_unimplemented]onFromValue), instead of a bare unsatisfied trait
bound.crates/sqlmodel/tests/uipins the diagnostics with trybuild snapshots (feature-off
cases under the default features, pass cases with the three features on). - C SQLite versus FrankenSQLite differential oracle. The e2e
sqlite_differentialtest runs
one ORM script (DDL, single and bulk inserts, constraint failures, upserts, every builder read
shape, joins, subqueries, EXISTS, aggregates and scalar functions,typeof()storage classes,
UPDATE/DELETE with RETURNING, transactions, introspection,sqlite_master) on both drivers in
lockstep and compares every observation after normalizing integer widths; error kinds are
compared, not messages. Unlisted divergences fail the test, and so does a listed divergence that
stopped diverging. The list holds five entries, all FrankenSQLite-side and cross-checked against
thesqlite3CLI: itslast_insert_rowid()drifts after a failed INSERT and after
INSERT ... RETURNING(a successfulinsert!still reports the right id on both), and it
storesCREATE TABLE IF NOT EXISTSverbatim insqlite_masterwhere C SQLite normalizes it.
The report prints the C SQLite and fsqlite versions. - Golden per-dialect SQL snapshots.
crates/sqlmodel-e2e/golden/<dialect>/<op>.sqlholds
the statement every builder emits for PostgreSQL, SQLite, and MySQL (103 snapshots: DDL with
indexes, comments, and identity keys; every INSERT/upsert/RETURNING shape; filters, paging,
DISTINCT, GROUP BY/HAVING, joins,IN (SELECT), EXISTS, FOR UPDATE, a window function; eager
table__columnprojections both ways; joined-inheritance child and polymorphic selects;
UPDATE/DELETE by model, filter, and subquery with RETURNING; CTEs including a recursive one;
UNION/EXCEPT; and the session's cascade-delete plan captured on SQLite). Thegolden_sqle2e
test compares byte for byte, prints a unified diff naming the op and dialect on a mismatch, and
rewrites the files only withSQLMODEL_UPDATE_GOLDEN=1.Select::build_eager_sql_with_dialect
exposes the eager statement; the facade now also re-exportsCte,CteRef,WithQuery,
SetOperation, andSetOpType, which were unreachable throughsqlmodelbefore. - Transactional, statement-by-statement migrations.
MigrationRunnersplits each migration
script on top-level semicolons (sqlmodel_schema::split_statements; strings, comments, and
PostgreSQL dollar quoting respected) and runs the statements one at a time. On PostgreSQL and
SQLite a migration and its tracking row are applied in one transaction, so a failing statement
leaves the database unchanged; on MySQL, where DDL commits implicitly, the statements before the
failure stay applied and no tracking row is written (documented onmigrate). A failure is an
Error::Schemaof kindMigrationnaming the migration, the direction, and the statement, with
the driver error as its source.Dialect::supports_transactional_ddlis new. A script that
manages its own transaction (the SQLite table-recreation scripts emitPRAGMA foreign_keys=OFF; BEGIN; ...; COMMIT; PRAGMA foreign_keys=ON) runs as written, since nesting it would fail at its
BEGINand the pragma has no effect inside a transaction. Until now a
multi-statement migration, which is exactly whatMigration::from_operationsproduces, could not
run on PostgreSQL at all (the extended protocol rejects several statements in one execute), and
nothing was ever transactional. SqlModelUpdate,UpdateOptionsandUpdateInput(thesqlmodel_updatefamily) are exported
fromsqlmodel_core's root and the facade's prelude; they were only reachable through
sqlmodel_core::validate. The e2e Session scenario now provesadd_alland every
sqlmodel_updateform on all five drivers.- Migration runners exclude each other.
MigrationRunner::migrateandrollbackhold a
server-side lock keyed by the tracking table for the whole call (pg_advisory_lockon
PostgreSQL,GET_LOCKwith a two-minute wait on MySQL), so two services starting at once do not
race: the second waits and then finds nothing pending. SQLite has no server to hold a lock; a
second runner there fails on the file lock or the tracking key instead of waiting and can be run
again, which the e2e race test asserts. Either way no migration is applied twice. - Migration checksums.
MigrationRunnerrecords a fingerprint of each applied migration's
upSQL (Migration::checksum, 64-bit FNV-1a) in a newchecksumcolumn of the tracking table
and compares it on every run: an applied migration whose SQL was edited afterwards is reported as
MigrationStatus::Driftedandmigraterefuses to run until it is rolled back or restored.
Rows recorded before this change have an empty checksum and are not verified. Proven on every
driver by the e2e migrations scenario. - New facade tests:
single_table_inheritance_sqlite.rs(first STI run against a database) and
migration_runner_sqlite.rs(firstMigrationRunnerrun against a database). - Security configuration as code:
.cargo/audit.toml(justified, dated ignores) anddeny.toml
(bans the AGENTS.md forbidden crates, advisories, licenses, registry sources).
Fixed
All of these were found by the first end-to-end runs of shipped code (the new e2e crate, the new
facade tests, and the driver integration suites run against live Docker databases), not by unit tests.
caching_sha2_passwordlogins (the MySQL 8 default) always failed withProtocol error: Unknown additional auth response: 01. The server frames the fast-auth status as an AuthMoreData
packet (0x01marker, then0x03/0x04); both the async and sync handlers matched the marker
byte itself. The marker is now stripped (auth::strip_auth_more_data_marker, also used for the RSA
public-key frame) before the status is interpreted, so cached fast-auth, RSA full-auth, and
TLS full-auth all complete against MySQL 8.4.- SCRAM-SHA-256 logins (the Postgres 14+ default) always failed with
Protocol error: Unexpected message during auth: ParameterStatus { name: "in_hot_standby", .. }. The SCRAM
exchange already consumed the server'sAuthenticationOk, but the surrounding auth loop kept
reading and rejected the first startupParameterStatus. Both the async and sync paths now
finish authentication when SCRAM completes; the startup loop then consumesParameterStatus,
BackendKeyData, andReadyForQueryas before. - A failed PostgreSQL statement desynchronized the connection. The extended-query and prepare
loops returned onErrorResponsewithout reading theReadyForQuerythat follows it, so the next
statement consumed that stale terminator and came back empty (the migration runner then believed
nothing had been applied and re-created every table). Both loops now drain toReadyForQuery
before surfacing the error. insert!(model).execute()never worked on PostgreSQL: the driver reads the new id from the
first result column but the builder emitted noRETURNING, so every insert failed with
"INSERT did not return an id". The builder now appendsRETURNING <pk>on PostgreSQL for models
with a single primary-key column; a non-integer key yields0, matching MySQL/SQLite.- MySQL
TEXTcolumns were decoded asValue::Bytes. TEXT and BLOB (and CHAR/VARCHAR versus
BINARY/VARBINARY) share wire types and differ only in the column charset, which the decoders
ignored. Text-protocol and binary-protocol decoding now return text unless the charset is binary
(63). This also madeIntrospector::table_infodrop every MySQL CHECK constraint, because
CHECK_CLAUSEisLONGTEXT. SchemaBuilder/CreateTablegenerated SQLite-only DDL for every database. Identifiers were
always double-quoted (MySQL rejects them) and an auto-increment primary key was always emitted as
SQLite'sINTEGER PRIMARY KEY, which on PostgreSQL and MySQL is a plain key that never
auto-assigns. Both builders now take aDialect(.dialect(conn.dialect()); the default stays
SQLite so existing output is unchanged): MySQL gets backticks,AUTO_INCREMENT, and no
unsupportedCREATE INDEX IF NOT EXISTS; PostgreSQL getsGENERATED BY DEFAULT AS IDENTITY.
Column types are dialect-aware too (SqlType::sql_name_for,FieldInfo::effective_sql_type_for):
BLOB/BINARYbecomeBYTEAandTINYINTbecomesSMALLINTon PostgreSQL; on MySQLUUID
becomesBINARY(16),TIMESTAMP/DATETIMEbecomeDATETIME(6),TIMESTAMPTZbecomes
TIMESTAMP(6),JSONBbecomesJSON,REALbecomesFLOAT. Before this a model with a
Vec<u8>orUuidfield could not have its table created on PostgreSQL or MySQL at all.- The C SQLite driver truncated timestamps and times to milliseconds.
Value::Timestamp,
Value::TimestampTz, andValue::Timeare microsecond integers, but the driver formatted them as
ISO text with a three-digit fraction, so2024-03-15T10:20:30.123456came back as.123. The
shared formatter (sqlmodel_core::value::{iso_date, iso_time, iso_timestamp}) now writes the
full six digits whenever the fraction is non-zero. Found by the e2e type round-trip on C SQLite. - The two SQLite drivers stored temporal values differently. C SQLite wrote ISO-8601 text while
FrankenSQLite wrote raw integers (days / microseconds), so a database written through one driver
read back wrong through the other and SQLite's own date functions could not use FrankenSQLite's
columns. FrankenSQLite now writes the same ISO text; thechronoconversions additionally accept
the integer forms so existing FrankenSQLite databases keep reading. - MySQL silently corrupted large floats bound through the text protocol.
1e300was written as
its 301-digit decimal expansion, which MySQL reads as an exact numeric literal capped at 65 digits,
so the column stored1e65. Float and double literals now use exponent form (1e300,-5e-1). - MySQL rejected every date, time, or timestamp bound through the text protocol. When the
driver inlines parameters into the statement it renderedValue::Date/Time/Timestampas their
raw integer payloads ('-25567'for 1900-01-01), soinsert!of a model with aNaiveDatefield
failed with "Incorrect date value". They now render as ISO literals. Session::get/get_with_optionsnever worked on MySQL. They built
SELECT * FROM "table" WHERE "id" = $1with ANSI quotes and PostgreSQL placeholders regardless of
dialect. Both now use the connection's dialect for quoting and placeholders (FOR UPDATErow
locking through the session now works on MySQL too).- Unique, indexed, primary-key, and foreign-key
Stringcolumns could not be created on
MySQL:Stringmaps toTEXT, which MySQL refuses to put in a key without a prefix length.
Such columns are declaredVARCHAR(255)on MySQL; plainStringcolumns stayTEXT. - INSERT/UPDATE/DELETE emitted unquoted column names, so a model with a column named
real,
double,blob,order, oruserfailed with a syntax error (MySQL rejects reserved words;
the other dialects have their own). Column names in these statements, inON CONFLICTtargets and
DO UPDATE/ON DUPLICATE KEY UPDATEassignments are now quoted for the dialect, matching the
Exprcolumns in WHERE. - Table names were emitted bare by every builder, so a table named
order,user, orgroup
broke on every dialect.SELECT/INSERT/UPDATE/DELETE,JOINclauses,EXISTSsubqueries,
eager-load joins, and the joined-inheritance projections ("table"."col" AS "table__col") now
quote table names through the newDialect::quote_table(schema-qualified names are quoted per
segment; already-quoted names pass through). The e2e smoke scenario now round-trips a table
calledorderwith columnsuserandselecton every driver. Exprignored operator precedence when rendering.a.or(b).and(c)rendered
a OR b AND c, which every database reads asa OR (b AND c), so any filter that combined an
orwith a laterand(including.filter(x.or(y)).filter(z)) returned the wrong rows. Children
that bind looser than their parent (or as tightly, on the right of-,/,%) are now
parenthesized, andNOTwraps a binary operand. Found by the new e2e expression scenario.- FrankenSQLite lost every column name for
SELECT * FROM "order". The driver names star
projections fromPRAGMA table_info(<table>), emitted unquoted; a reserved-word table name made
the pragma a syntax error and the row came back nameless. The table is now always quoted. - PostgreSQL introspection never reported a primary key.
Introspector::table_infoderives
the primary key from the columns'primary_keyflag, and the PostgreSQL column query left that
flagfalsewith a comment promising a separate index query that did not exist, so every
PostgreSQL table introspected as keyless andschema_diffalways wanted to add the primary key
again. Primary-key columns are now read frominformation_schema.table_constraints. Found by
the new e2e schema fixpoint scenario (introspect, diff, generate, apply, introspect, diff empty). schema_diffcreated tables in hash-map order. A generated migration could create a table
before the table its foreign key references; MySQL rejects that outright ("Failed to open the
referenced table") and PostgreSQL only worked when the hashing happened to cooperate. New tables
are now created in foreign-key dependency order (ties by name, so output is deterministic) and
dropped in the reverse order. Found by the e2e schema fixpoint scenario on MySQL.- The expected schema built from models was dialect-blind.
table_schema_from_model,
table_schema_from_fields,ModelSchema::table_schema, andModelTuple::all_table_schemas
mapped field types without the dialect, so a generated migration declared a keyedStringas
TEXTon MySQL ("BLOB/TEXT column used in key specification without a key length") while
SchemaBuilderhad already learned to sayVARCHAR(255). They now take the dialect and use the
sameFieldInfo::effective_sql_type_forrule, so the schema the differ expects is the schema
the generated DDL creates. Found by the e2e schema fixpoint scenario on MySQL. - SQLite rollbacks of recreate-based changes were not runnable.
SchemaOperation::inverse
dropped the table snapshot, so the SQLite generator rendered thedownscript of an altered
column type, nullability or default, or an added primary key, foreign key or unique constraint as
SELECT __sqlmodel_error__('... requires table_info'), which fails the moment it is run. The
inverse now carries the table as it is after the forward operation and the rollback rebuilds the
table. Found by the e2e schema fixpoint scenario rolling back a nullability change on SQLite. - FrankenSQLite kept a stale snapshot after a failed
COMMIT. When a commit failed on a
snapshot conflict the driver returned the error but neither rolled back nor cleared its
in-transaction flag, so the connection kept reading the old snapshot: a retry did not see what
the other writer had committed and re-created tables that already existed. A failed commit now
rolls back and returns the connection to autocommit on a fresh snapshot. Found by the e2e
migration-runner race on FrankenSQLite; unit test with two connections. schema_diffreported two false differences on MySQL.BOOLEANcomes back from the server
astinyint(1)and integers keep their display widths, which the normalizer treated as type
changes; and the index MySQL creates for every foreign key was reported as an index to drop,
which MySQL would have refused. Display widths are ignored and implicit foreign-key indexes are
not differences. Found by the e2e schema fixpoint scenario.- MySQL introspection never reported a foreign key. The foreign-key query read
information_schemacolumns by their lower-case names, but MySQL returns them upper-cased
(CONSTRAINT_NAME), so every row was dropped andschema_diffkept wanting to add the keys.
The query now aliases each column. Found by the e2e schema fixpoint scenario on MySQL, together
with a new MySQL integration test that introspects a table with a foreign key and an index. - MySQL: a row whose first value is an empty string desynchronized the connection. In the
text protocol such a row starts with the byte0x00, which both result-set readers took for an
OK packet: they stopped reading, dropped that row and every row after it, and left the real
terminator in the stream, so the next statement failed with "Protocol error: Invalid column
count". Binary-protocol rows always start with0x00and were affected the same way. Inside a
result set only0xFE(EOF, or OK whenCLIENT_DEPRECATE_EOFis negotiated) ends the rows and
0xFFis an error; everything else is a row. Found by the e2e schema fixpoint scenario: the
introspector readsTABLE_COMMENT, which is the empty string for a table without a comment. Row::get_namednow matches column names case-insensitively when no exact match exists and
the match is unambiguous, since unquoted SQL identifiers are case-insensitive and servers report
them in their own case (PostgreSQL lower, MySQLinformation_schemaupper).#[sqlmodel(skip_insert)]and#[sqlmodel(skip_update)]did nothing. The derive accepted
both attributes and dropped them. They now reachFieldInfo(skip_insert,skip_update) and
are honored byinsert!, bulk inserts,update!, and the session's flush: askip_insert
column is left to the database (server default, trigger), askip_updatecolumn is never
written back. Found while building the e2e attribute corpus.- Declared CHECK constraints and column comments never reached the DDL.
check = "..."/
sa_column(check = ...)andcomment = "..."were carried inFieldInfobutSchemaBuilder,
CreateTableandALTER TABLE ... ADD COLUMNignored them. CHECK constraints are now rendered
inline on every dialect; comments inline on MySQL and asCOMMENT ON COLUMNstatements on
PostgreSQL (SQLite has no column comments). The expected schema built from models carries both
as well. #[sqlmodel(index = "...")]never created the index.SchemaBuilder::create_tableemitted
the table only, so a declared index existed in the expected schema (and the differ kept asking
for it) but never in the database unless the caller repeated it throughcreate_indexby hand.
create_tablenow emits aCREATE INDEXper declared field index, using the dialect's form
(IF NOT EXISTSwhere supported, none on MySQL).- PostgreSQL schema diff saw
CHARACTER VARYING(40)andVARCHAR(40)as different types.
information_schemareports the SQL-standard spellings (character varying,character,
timestamp without time zone, ...) while models declare the aliases, so everyVARCHAR,
CHAR,TIMESTAMP,TIME, andDECIMALcolumn produced a spuriousAlterColumnTypeon each
run. The differ's PostgreSQL type normalization now canonicalizes the base name and keeps the
length/precision suffix. - An
Option<i64>primary key was expected to be nullable. The expected schema copied the
field's nullability onto the primary-key column, so the idiomatic "assigned by the database"
key diffed asAlterColumnNullableon PostgreSQL and MySQL (where a primary key is NOT NULL)
forever. A primary-key column is now NOT NULL in the expected schema, and the SQLite
introspector reports it the same way (PRAGMA table_infosaysnotnull = 0for a rowid alias
that can never hold NULL). - A relationship's
related_tablewas the model's type name. The derive emitted the
model = "Team"text as the related table, so an eager JOIN targeted a table calledTeam.
The related table now comes from the related type's ownTABLE_NAME. - One-to-many relationships put the child's foreign key on the wrong side. The derive
emittedforeign_key = "team_id"aslocal_keyfor every kind; forRelatedMany<T>the
column lives on the child, which isremote_key(what eager JOINs and session cascades read),
so those looked for the child's column on the parent table. - A JOIN made
select!(Model)map the wrong table. WithSELECT *over a JOIN the joined
table's same-named columns (id,name) win the by-name lookup, so the model was silently
built from the joined table's values. With joins present the query now projects the model's
own columns (SELECT "players".* ...); FrankenSQLite's text-based column naming accepts that
qualified star with a JOIN. - PostgreSQL
insert!(..).on_conflict_do_nothing().execute()failed on the conflict. The
builder appendsRETURNING idon PostgreSQL to learn the key, and a skipped insert returns no
row, which the driver reported as an error. With a conflict clause the result is now the key of
the inserted or updated row, else the model's own key, else 0. Expr::in_list(vec![Expr::subquery(..)])renderedIN ((SELECT ...)). The doubled
parentheses make the subquery a scalar one, which PostgreSQL rejects as soon as it yields two
rows. A lone subquery is now the IN list itself; a unit test had pinned the wrong form.- Auto-increment keys introspected as plain columns on PostgreSQL and SQLite. The PostgreSQL
column query only recognisednextval(...)defaults, so the identity columns the schema builder
emits (GENERATED BY DEFAULT AS IDENTITY) read back asauto_increment = false; SQLite never
reported it at all becausePRAGMA table_infocannot. The query now reads
information_schema.columns.is_identity, and a single-columnINTEGER PRIMARY KEY(SQLite's
auto-assigning rowid alias) is reported asauto_increment, so an auto-increment model reads back
the same on every engine (asserted by the e2eattributesscenario). - A migration that dropped a primary key could not be rolled back.
DropPrimaryKey::inverse
wasNone, soMigration::from_operationsfor a key change (idto(id, team_id)) wrote an
emptydownscript and the runner reported the rollback as unrunnable. The inverse is now an
AddPrimaryKeyof the columns recorded in the pre-forward snapshot, carrying the post-forward
snapshot for SQLite's table recreation (the bug-37 shape). The e2eschema_fixpointscenario
now moves a populated table's key fromidto a composite key and back, and gives a keyless,
populated log table a key and takes it away again, on every driver. - The pool failed an
acquireinstead of replacing a dead idle connection. With
test_on_checkouta connection that failed its ping was closed and the acquire returned a
Disconnectederror, leaving the caller to retry; it now moves on to the next idle connection
or opens a new one. Proven on PostgreSQL and MySQL by killing the idle session on the server
(pg_terminate_backend/KILL CONNECTION): the next acquire hands out a new server session
andconnections_closedgrows by one. The never-implementedtest_on_returnoption is gone
(a return is a synchronousDrop); the documented rule is todetacha lease whose statement
failed with a connection error, which the same scenario asserts. The crate-level example showed
aPool::new(config, factory)/acquire(&cx)API that does not exist; it now shows the real
one. Lifetime retirement is also proven to open a different server session. - A full pool deadlocked a single-threaded runtime. A task waiting for a lease blocked the
runtime thread in astd::sync::Condvar::wait_timeout, so the tasks holding the leases could
never run to return them; every waiter timed out (4 x max tasks on a current-thread runtime
against PostgreSQL took 93 s and failed). The wait is now an asupersyncNotifyraced against
a timer slice, so waiters yield. A pool unit test contends four tasks on a pool of one under a
current-thread runtime; the e2e fan-out is the live proof. - MySQL
COM_STMT_EXECUTEdeclared the wrong parameter types. The execute packet copied the
placeholder types from the prepare response (MySQL reportsVAR_STRINGfor every?) while
encoding each value in its own binary form, so any integer, float, boolean, or temporal
parameter was rejected with "Malformed communication packet". The packet now declares the type
of each value as encoded. Surfaced the moment the ORM started using the prepared path. - API polish after the live rounds.
MigrationRunner::lock_timeout(Duration)(default two
minutes,DEFAULT_LOCK_TIMEOUT) bounds the wait for another runner's migration lock on both
servers: PostgreSQL pollspg_try_advisory_lockuntil the deadline (it used to block in
pg_advisory_lockwithout limit), MySQL passes the seconds toGET_LOCK(hard-coded 120
before).MigrationStatus::Failedis gone: no code path ever produced it.Row::contains/
contains_columnresolve names exactly likeget_named(exact, then unambiguous
case-insensitive), so they can no longer disagree. The MySQL first-packet classification
(PacketType::from_first_byte) was re-audited: its five call sites all classify the first
response packet (authentication result,COM_QUERY,COM_STMT_EXECUTE), where0x00is an OK
packet; every row phase already usesRowPacket::classify. - MySQL unsigned integers above the signed range came back negative. Both decoders cast an
unsigned column into the same-width signedValue(TINYINT UNSIGNED200 read as -56,
BIGINT UNSIGNED18446744073709551615 read as -1), and the binary decoder ignored the unsigned
flag entirely. Unsigned values are now widened to the next signed variant, and an unsigned
64-bit value abovei64::MAXis carried exactly asValue::Decimal. Found by the new
binary-protocol integration test against MySQL 8.4. - The N+1 query detector never saw a query.
Session::enable_n1_detectioninstalled a tracker,
but no loader ever calledrecord_lazy_load, son1_stats()stayed at zero however many
per-parent loads a loop issued.load_lazynow records one load per call under the child's
table and the batch loaders (load_many,load_one_to_many,load_many_to_many) record one
load per call, so a per-parent loop crosses the threshold and a batch does not. Proven on every
driver by the e2e Session scenario. sqlmodel_update_fromfailed for any model with relationship fields. It serialized the
whole patch model and then rejectedbooks(or anyRelatedMany/Lazyfield) as an unknown
field, so a model with relationships could never be patched from another instance. Only the
model's columns are taken from the patch now.Session::mark_dirtyignored expired objects. Every object is expired aftercommit, so
modifying an object obtained before the commit and callingmark_dirtywas a silent no-op and
the change never reached the database. An expired object is now persistent and dirty again,
as withmerge.Session::rollbackkept rolled-back inserts as persistent. An object INSERTed by a flush
inside a transaction that was then rolled back stayedPersistentin the identity map although
its row was gone, soadding it again was a silent no-op and the next commit wrote nothing.
Rollback now drops objects that were inserted in the rolled-back transaction (they are transient
again) and expires the remaining persistent ones, so stale in-memory state is reloaded. Found by
the e2eadd_allscenario.Session::flushset every column on every UPDATE. A dirty object was written back with
all of its non-key columns, which resent unchanged values on every save and turned two sessions
editing different columns of the same row into a lost update. The UPDATE now names only the
columns that differ from the object's snapshot, and an object whose values did not change
produces no statement. Proven with the newCapturingConnectionin the e2e crate on every
driver.Session::mergeonto an expired object was lost. Every tracked object is expired after
commit; merging a detached copy onto one replaced its values but never marked it dirty, so the
following flush issued noUPDATEand the change silently vanished. The merged object is now
persistent and dirty again. Found by the e2e Session scenario on a real database.Session::load_many_to_manyresolved zero links on FrankenSQLite. The loader selected
child.*across a JOIN, which that driver cannot name from the schema, so no row's parent key or
columns could be read. The loader now projects the child's columns explicitly, which every driver
names identically.- Decimals lost precision on SQLite. A
DECIMAL(p, s)column has NUMERIC affinity, which turns
the bound text into a REAL and keeps 15 significant digits, so a 20-digitrust_decimal::Decimal
came back rounded; FrankenSQLite additionally bound decimals as floats. Decimal and numeric
columns are now declaredTEXTon SQLite (exact; SQL-side ordering and arithmetic on them are
textual) and both SQLite drivers bindValue::Decimalas text. - FrankenSQLite lost column names for
SELECT *, expr AS alias FROM t. The star-projection
schema lookup only handled a bare*, so the session's one-to-many loader (SELECT *, fk AS __parent_pk ...) got placeholder names and found no children. Extra select items are now named by
their alias or column and appended after the table's columns. - A cancelled
Cxstill executed statements. AGENTS.md and the README promised that every
database operation honours cancellation, but the C SQLite, FrankenSQLite, and MySQL drivers
never looked at theCx(PostgreSQL only through its connection mutex), andSession::flush
happily inserted rows under a pre-cancelled context. EveryConnectionoperation in every driver
now returnsOutcome::Cancelledbefore touching the database when the context is already
cancelled (sqlmodel_core::cancel_requested), as doSession::flushandSession::begin. The
SQLite drivers are synchronous, so this entry check is the whole cancellation story there; the
network drivers additionally stop at their cancel-aware lock. Found by the e2e Session scenario. Session::load_one_to_manyreturned no children on C SQLite. Children were grouped by a
hash of the parent keyValuethat distinguishedIntfromBigInt; C SQLite reports any
integer that fitsi32asInt, so a parent looked up byBigInt(2)never matched its rows'
__parent_pkofInt(2). Integer widths now hash identically (the same normalization the
identity map keys use). Found by the first Session run on a real database.cargo doc --workspacefailed onsqlmodel-pool: one retirement path still called
Cx::for_testing(), which only compiled through dev-dependency feature unification. It now uses
the runtime-owned request context like the rest of the pool.select!(Model)never worked through the FrankenSQLite driver. The adapter derived result
column names by parsing the SQL text and could not expandSELECT *, so every model hydration
failed with "column not found". Column names forSELECT */SELECT t.*(single table) and
RETURNING *now come fromPRAGMA table_info.i8/i16/i32fields rejected in-range integers reported as a widerValuevariant.
FrankenSQLite reports every INTEGER asValue::BigInt;FromValue/TryFromfor the narrower
types only accepted their own width (C SQLite masked this by reportingInt). Narrowing is now
range-checked: accepted when the value fits, refused with the offending value otherwise, never
truncated.#[derive(Model)]silently turned type errors onOption<T>fields intoNone(it used
.ok()). Only a NULL value or an absent column now hydrates asNone; a value of the wrong type
is an error.DeleteBuilderandUpdateBuilderignored the single-table-inheritance discriminator:
delete!(Manager).filter(...)removed rows of every kind sharing the table. Both builders now fold
the implicittable.discriminator = valuepredicate into their WHERE clause (SELECT already did).- FrankenSQLite busy/snapshot-conflict errors were not retryable.
BusySnapshotand
SnapshotTooOldmap toQueryErrorKind::Serialization,Busy/BusyRecoverytoTimeout, so
Error::is_retryable()is true andretry_transactionretries them instead of failing on the
first concurrent-writer conflict. MigrationRunnercould not work on MySQL: the tracking table usedTEXT PRIMARY KEY(MySQL
requires a key length) and the record/delete statements hard-coded PostgreSQL$nplaceholders
(SQLite only accepted them by treating$1as a named parameter). NowVARCHAR(255),BIGINT,
andDialect::placeholder.SchemaBuilder::create_tablefor single-table-inheritance children emittedALTER TABLE ... ADD COLUMNfor columns the base model already declares (a child that redeclaresnameto be able
to insert it broke DDL with a duplicate column). Inherited columns are now skipped.- Two unresolved rustdoc links in
sqlmodel-postgres's protocol reader madecargo doc -D warnings
fail (bd-o59n).
Changed
sqlmodel_schema::Dialectis now the coresqlmodel_core::Dialect(re-exported) instead of a
second, identical-looking enum.Introspector::new(conn.dialect())andSchemaBuilder::new() .dialect(conn.dialect())now type-check; the only observable difference is that
DatabaseSchema::default()reports the core default dialect (PostgreSQL) rather than SQLite.
The facade root additionally exportsDialect,Related, andRelatedMany(the prelude gains
Dialect), so models with relationship fields no longer need asqlmodel_coredependency.sqlmodel-mysqlno longer depends on the unmaintainedrustls-pemfile(RUSTSEC-2025-0134); PEM
parsing usesrustls::pki_types::pem. Behavior preserved, covered by fixture tests.- Dependencies: asupersync 0.4.9 → 0.4.10, fsqlite family 0.3.13 → 0.3.14 (manifest requirements
aligned to 0.3.14), yanked chacha20 0.10.1 → 0.10.2, 55-package transitive refresh. Details and
release-note research in UPGRADE_LOG.md. - CI: new
integrationjob with PostgreSQL 16, MySQL 8.4, and MariaDB 11 services that fails if a
suite skips; Security job nowcargo audit --deny warnings+cargo deny checkand gates the
release build; removed the path-dependency-eragit cloneof asupersync/rich_rust and the
Windows vcpkg SQLite install (libsqlite3-sys is bundled); MSRV job also runs tests; lint job checks
that Cargo.toml, README, and CHANGELOG agree on the version. Connection::closereturningResultis documented as the deliberate exception to the
Outcomeinvariant (PROPOSED_RUST_ARCHITECTURE.md §9).
Docs
- README: FrankenSQLite in the architecture diagram and crate table; a transactions/concurrent
writers/retry section; truthful production-readiness FAQ; the obsolete "edition 2024 is
unstable" troubleshooting entry replaced with the real nightly reasons. FEATURE_PARITY test-coverage
table now lists every crate with real-database status. PLAN_TO_PORT marks phases complete and
records that success criteria are unmeasured. EXISTING_SQLMODEL_STRUCTURE §12 rewritten from
"exclusions" to design differences. AGENTS.md: real test locations,deny.tomlenforcement note,
RCHrch execnote. Newcrates/sqlmodel-frankensqlite/README.md;crates/sqlmodel-mysql/README.md
gains a security note on RUSTSEC-2023-0071. SESSION_TODO.md retired in place.