Skip to content

v0.4.3: Lockstep Release of All 12 Crates

Latest

Choose a tag to compare

@Dicklesworthstone Dicklesworthstone released this 08 Sep 16:55
· 10 commits to main since this release

[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 module sqlmodel::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 (asupersync Cx, budgets, transaction
    guards), and testing patterns (in-memory SQLite, isolation, LabRuntime, CancelAt sweeps).
    Every chapter ends with "Differences from Python SQLModel". All snippets are verified via doctests
    rendered in rustdoc as sqlmodel::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 under TransactionMode::Concurrent). Between attempts the open
    transaction is rolled back and the identity map cleared, so the closure re-adds what it needs;
    backoff respects the Cx budget and a cancelled Cx is never retried. Built on the new
    RetryPolicy::after_failure / RetryDecision, which retry_transaction now shares, so any
    caller can put a jittered, budget-aware retry loop around anything else.
  • nightly-try feature (sqlmodel and sqlmodel-core): enables asupersync's Try
    implementation so ? works on Outcome, 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.md is a doctest of the sqlmodel
    facade (#[doc = include_str!], built with --features nightly-try); the three blocks that are
    not programs (console output, attribute cheat-sheet, Expr catalogue) 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: a Select::to_sql method (the real API is build_with_dialect,
    now shown with its SQL asserted) and ? on Outcome, which never compiled without the feature.
  • Concurrent writers through the public API. sqlmodel_core::TransactionMode
    (Default | Concurrent | Immediate | Exclusive | Deferred) and TransactionOptions, plus
    Connection::supports_transaction_mode and Connection::begin_with_options (default
    implementation refuses unsupported modes with the new
    TransactionErrorKind::UnsupportedMode instead of downgrading). FrankenSQLite maps
    Concurrent to BEGIN CONCURRENT (page-level MVCC; previously reachable only via raw SQL) and
    gains begin_concurrent_sync; C SQLite offers the three locking forms and rejects Concurrent
    with a message pointing at sqlmodel-frankensqlite; PostgreSQL and MySQL accept Concurrent as
    their native MVCC default. SessionConfig::transaction_mode /
    SessionConfig::with_transaction_mode make Session start its transactions in a chosen mode.
    Re-exported from the facade and its prelude.
  • retry_transaction and RetryPolicy (sqlmodel_core::retry): re-run a whole transaction
    when it fails with an error Error::is_retryable() accepts (serialization failures, write
    conflicts, deadlocks), with jittered exponential backoff that never sleeps past the Cx budget
    deadline, never retries after Cancelled/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, and CapturingConnection, 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, when SQLMODEL_TEST_{POSTGRES,MYSQL,MARIADB}_URL
    are set, PostgreSQL/MySQL/MariaDB. Scenarios: model CRUD smoke (including a table named order
    with columns user and select), every Expr family through select! (comparisons, NULL
    tests, LIKE, IN, BETWEEN, boolean composition, string/numeric/conditional functions, GROUP BY
    with HAVING, DISTINCT, paging, EXISTS; ILIKE is 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 with retry_transaction (no lost updates; C SQLite asserts the
    UnsupportedMode refusal). The attributes scenario is a conformance corpus for every
    #[sqlmodel(...)] field attribute in one model set (primary_key + auto_increment on an
    Option<i64>, unique, nullable, column renames, column_comment, column_constraints,
    default, index, sql_type, foreign_key with on_delete/on_update CASCADE 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,
    composite get_by_pk, cascades) on every driver. Its first run found bugs 44-49 below.
    The operations scenario 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 explicit Join builder (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>, and RelatedMany<T> field) now receives the joined rows grouped per
    parent, so a to-one field is loaded (or loaded-as-None for 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_query take a subquery from
    Select::into_query (now public) and render it for the enclosing statement's dialect with
    renumbered parameters.
  • RETURNING on MySQL. execute_returning on the insert, update, and delete builders used to
    send RETURNING * 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-export EagerLoader, IncludePath,
    InsertBuilder, InsertManyBuilder, UpdateBuilder, DeleteBuilder, OnConflict,
    SelectQuery, and the insert_many! macro; bulk inserts and eager loading previously needed
    a direct sqlmodel-query dependency.
  • 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's pg_stat_activity (the pool's
    sessions carry an application_name) and MySQL's information_schema.processlist report
    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 /
    insert with parameters and ? placeholders now go through COM_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, $n placeholders,
    and the statement kinds MySQL refuses to prepare (ER_UNSUPPORTED_PS) keep the text protocol.
    An integration test reads the server's Com_stmt_prepare / Com_stmt_execute /
    Com_stmt_close counters: 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, the Parse
    step is skipped entirely and Bind + Describe Portal + Execute + Sync are executed directly.
    When capacity is exceeded, the least recently used statement is closed on the server via Close(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 20 Session::get calls on one connection and reads the server: MySQL shows one
    Com_stmt_prepare and 20 Com_stmt_execute (the driver's cache), PostgreSQL shows one
    statement in pg_prepared_statements and 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::cache docs 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_decimal field built without the matching sqlmodel feature now fails with
    "chrono::NaiveDateTime cannot be read from a database row" plus a note naming the feature to
    enable (#[diagnostic::on_unimplemented] on FromValue), instead of a bare unsatisfied trait
    bound. crates/sqlmodel/tests/ui pins 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_differential test 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
    the sqlite3 CLI: its last_insert_rowid() drifts after a failed INSERT and after
    INSERT ... RETURNING (a successful insert! still reports the right id on both), and it
    stores CREATE TABLE IF NOT EXISTS verbatim in sqlite_master where C SQLite normalizes it.
    The report prints the C SQLite and fsqlite versions.
  • Golden per-dialect SQL snapshots. crates/sqlmodel-e2e/golden/<dialect>/<op>.sql holds
    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__column projections 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). The golden_sql e2e
    test compares byte for byte, prints a unified diff naming the op and dialect on a mismatch, and
    rewrites the files only with SQLMODEL_UPDATE_GOLDEN=1. Select::build_eager_sql_with_dialect
    exposes the eager statement; the facade now also re-exports Cte, CteRef, WithQuery,
    SetOperation, and SetOpType, which were unreachable through sqlmodel before.
  • Transactional, statement-by-statement migrations. MigrationRunner splits 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 on migrate). A failure is an
    Error::Schema of kind Migration naming the migration, the direction, and the statement, with
    the driver error as its source. Dialect::supports_transactional_ddl is new. A script that
    manages its own transaction (the SQLite table-recreation scripts emit PRAGMA foreign_keys=OFF; BEGIN; ...; COMMIT; PRAGMA foreign_keys=ON) runs as written, since nesting it would fail at its
    BEGIN and the pragma has no effect inside a transaction. Until now a
    multi-statement migration, which is exactly what Migration::from_operations produces, could not
    run on PostgreSQL at all (the extended protocol rejects several statements in one execute), and
    nothing was ever transactional.
  • SqlModelUpdate, UpdateOptions and UpdateInput (the sqlmodel_update family) are exported
    from sqlmodel_core's root and the facade's prelude; they were only reachable through
    sqlmodel_core::validate. The e2e Session scenario now proves add_all and every
    sqlmodel_update form on all five drivers.
  • Migration runners exclude each other. MigrationRunner::migrate and rollback hold a
    server-side lock keyed by the tracking table for the whole call (pg_advisory_lock on
    PostgreSQL, GET_LOCK with 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. MigrationRunner records a fingerprint of each applied migration's
    up SQL (Migration::checksum, 64-bit FNV-1a) in a new checksum column of the tracking table
    and compares it on every run: an applied migration whose SQL was edited afterwards is reported as
    MigrationStatus::Drifted and migrate refuses 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 (first MigrationRunner run against a database).
  • Security configuration as code: .cargo/audit.toml (justified, dated ignores) and deny.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_password logins (the MySQL 8 default) always failed with Protocol error: Unknown additional auth response: 01. The server frames the fast-auth status as an AuthMoreData
    packet (0x01 marker, then 0x03/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's AuthenticationOk, but the surrounding auth loop kept
    reading and rejected the first startup ParameterStatus. Both the async and sync paths now
    finish authentication when SCRAM completes; the startup loop then consumes ParameterStatus,
    BackendKeyData, and ReadyForQuery as before.
  • A failed PostgreSQL statement desynchronized the connection. The extended-query and prepare
    loops returned on ErrorResponse without reading the ReadyForQuery that 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 to ReadyForQuery
    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 no RETURNING, so every insert failed with
    "INSERT did not return an id". The builder now appends RETURNING <pk> on PostgreSQL for models
    with a single primary-key column; a non-integer key yields 0, matching MySQL/SQLite.
  • MySQL TEXT columns were decoded as Value::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 made Introspector::table_info drop every MySQL CHECK constraint, because
    CHECK_CLAUSE is LONGTEXT.
  • SchemaBuilder / CreateTable generated 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's INTEGER PRIMARY KEY, which on PostgreSQL and MySQL is a plain key that never
    auto-assigns. Both builders now take a Dialect (.dialect(conn.dialect()); the default stays
    SQLite so existing output is unchanged): MySQL gets backticks, AUTO_INCREMENT, and no
    unsupported CREATE INDEX IF NOT EXISTS; PostgreSQL gets GENERATED BY DEFAULT AS IDENTITY.
    Column types are dialect-aware too (SqlType::sql_name_for, FieldInfo::effective_sql_type_for):
    BLOB/BINARY become BYTEA and TINYINT becomes SMALLINT on PostgreSQL; on MySQL UUID
    becomes BINARY(16), TIMESTAMP/DATETIME become DATETIME(6), TIMESTAMPTZ becomes
    TIMESTAMP(6), JSONB becomes JSON, REAL becomes FLOAT. Before this a model with a
    Vec<u8> or Uuid field 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, and Value::Time are microsecond integers, but the driver formatted them as
    ISO text with a three-digit fraction, so 2024-03-15T10:20:30.123456 came 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; the chrono conversions additionally accept
    the integer forms so existing FrankenSQLite databases keep reading.
  • MySQL silently corrupted large floats bound through the text protocol. 1e300 was written as
    its 301-digit decimal expansion, which MySQL reads as an exact numeric literal capped at 65 digits,
    so the column stored 1e65. 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 rendered Value::Date/Time/Timestamp as their
    raw integer payloads ('-25567' for 1900-01-01), so insert! of a model with a NaiveDate field
    failed with "Incorrect date value". They now render as ISO literals.
  • Session::get / get_with_options never worked on MySQL. They built
    SELECT * FROM "table" WHERE "id" = $1 with ANSI quotes and PostgreSQL placeholders regardless of
    dialect. Both now use the connection's dialect for quoting and placeholders (FOR UPDATE row
    locking through the session now works on MySQL too).
  • Unique, indexed, primary-key, and foreign-key String columns could not be created on
    MySQL
    : String maps to TEXT, which MySQL refuses to put in a key without a prefix length.
    Such columns are declared VARCHAR(255) on MySQL; plain String columns stay TEXT.
  • INSERT/UPDATE/DELETE emitted unquoted column names, so a model with a column named real,
    double, blob, order, or user failed with a syntax error (MySQL rejects reserved words;
    the other dialects have their own). Column names in these statements, in ON CONFLICT targets and
    DO UPDATE / ON DUPLICATE KEY UPDATE assignments are now quoted for the dialect, matching the
    Expr columns in WHERE.
  • Table names were emitted bare by every builder, so a table named order, user, or group
    broke on every dialect. SELECT/INSERT/UPDATE/DELETE, JOIN clauses, EXISTS subqueries,
    eager-load joins, and the joined-inheritance projections ("table"."col" AS "table__col") now
    quote table names through the new Dialect::quote_table (schema-qualified names are quoted per
    segment; already-quoted names pass through). The e2e smoke scenario now round-trips a table
    called order with columns user and select on every driver.
  • Expr ignored operator precedence when rendering. a.or(b).and(c) rendered
    a OR b AND c, which every database reads as a OR (b AND c), so any filter that combined an
    or with a later and (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, and NOT wraps a binary operand. Found by the new e2e expression scenario.
  • FrankenSQLite lost every column name for SELECT * FROM "order". The driver names star
    projections from PRAGMA 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_info derives
    the primary key from the columns' primary_key flag, and the PostgreSQL column query left that
    flag false with a comment promising a separate index query that did not exist, so every
    PostgreSQL table introspected as keyless and schema_diff always wanted to add the primary key
    again. Primary-key columns are now read from information_schema.table_constraints. Found by
    the new e2e schema fixpoint scenario (introspect, diff, generate, apply, introspect, diff empty).
  • schema_diff created 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, and ModelTuple::all_table_schemas
    mapped field types without the dialect, so a generated migration declared a keyed String as
    TEXT on MySQL ("BLOB/TEXT column used in key specification without a key length") while
    SchemaBuilder had already learned to say VARCHAR(255). They now take the dialect and use the
    same FieldInfo::effective_sql_type_for rule, 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 the down script 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_diff reported two false differences on MySQL. BOOLEAN comes back from the server
    as tinyint(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_schema columns by their lower-case names, but MySQL returns them upper-cased
    (CONSTRAINT_NAME), so every row was dropped and schema_diff kept 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 byte 0x00, 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 with 0x00 and were affected the same way. Inside a
    result set only 0xFE (EOF, or OK when CLIENT_DEPRECATE_EOF is negotiated) ends the rows and
    0xFF is an error; everything else is a row. Found by the e2e schema fixpoint scenario: the
    introspector reads TABLE_COMMENT, which is the empty string for a table without a comment.
  • Row::get_named now 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, MySQL information_schema upper).
  • #[sqlmodel(skip_insert)] and #[sqlmodel(skip_update)] did nothing. The derive accepted
    both attributes and dropped them. They now reach FieldInfo (skip_insert, skip_update) and
    are honored by insert!, bulk inserts, update!, and the session's flush: a skip_insert
    column is left to the database (server default, trigger), a skip_update column 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 = ...) and comment = "..." were carried in FieldInfo but SchemaBuilder,
    CreateTable and ALTER TABLE ... ADD COLUMN ignored them. CHECK constraints are now rendered
    inline on every dialect; comments inline on MySQL and as COMMENT ON COLUMN statements 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_table emitted
    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 through create_index by hand.
    create_table now emits a CREATE INDEX per declared field index, using the dialect's form
    (IF NOT EXISTS where supported, none on MySQL).
  • PostgreSQL schema diff saw CHARACTER VARYING(40) and VARCHAR(40) as different types.
    information_schema reports the SQL-standard spellings (character varying, character,
    timestamp without time zone, ...) while models declare the aliases, so every VARCHAR,
    CHAR, TIMESTAMP, TIME, and DECIMAL column produced a spurious AlterColumnType on 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 as AlterColumnNullable on 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_info says notnull = 0 for a rowid alias
    that can never hold NULL).
  • A relationship's related_table was the model's type name. The derive emitted the
    model = "Team" text as the related table, so an eager JOIN targeted a table called Team.
    The related table now comes from the related type's own TABLE_NAME.
  • One-to-many relationships put the child's foreign key on the wrong side. The derive
    emitted foreign_key = "team_id" as local_key for every kind; for RelatedMany<T> the
    column lives on the child, which is remote_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. With SELECT * 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 appends RETURNING id on 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(..)]) rendered IN ((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 recognised nextval(...) defaults, so the identity columns the schema builder
    emits (GENERATED BY DEFAULT AS IDENTITY) read back as auto_increment = false; SQLite never
    reported it at all because PRAGMA table_info cannot. The query now reads
    information_schema.columns.is_identity, and a single-column INTEGER PRIMARY KEY (SQLite's
    auto-assigning rowid alias) is reported as auto_increment, so an auto-increment model reads back
    the same on every engine (asserted by the e2e attributes scenario).
  • A migration that dropped a primary key could not be rolled back. DropPrimaryKey::inverse
    was None, so Migration::from_operations for a key change (id to (id, team_id)) wrote an
    empty down script and the runner reported the rollback as unrunnable. The inverse is now an
    AddPrimaryKey of the columns recorded in the pre-forward snapshot, carrying the post-forward
    snapshot for SQLite's table recreation (the bug-37 shape). The e2e schema_fixpoint scenario
    now moves a populated table's key from id to 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 acquire instead of replacing a dead idle connection. With
    test_on_checkout a connection that failed its ping was closed and the acquire returned a
    Disconnected error, 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
    and connections_closed grows by one. The never-implemented test_on_return option is gone
    (a return is a synchronous Drop); the documented rule is to detach a lease whose statement
    failed with a connection error, which the same scenario asserts. The crate-level example showed
    a Pool::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 a std::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 asupersync Notify raced 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_EXECUTE declared the wrong parameter types. The execute packet copied the
    placeholder types from the prepare response (MySQL reports VAR_STRING for 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 polls pg_try_advisory_lock until the deadline (it used to block in
    pg_advisory_lock without limit), MySQL passes the seconds to GET_LOCK (hard-coded 120
    before). MigrationStatus::Failed is gone: no code path ever produced it. Row::contains /
    contains_column resolve names exactly like get_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), where 0x00 is an OK
    packet; every row phase already uses RowPacket::classify.
  • MySQL unsigned integers above the signed range came back negative. Both decoders cast an
    unsigned column into the same-width signed Value (TINYINT UNSIGNED 200 read as -56,
    BIGINT UNSIGNED 18446744073709551615 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 above i64::MAX is carried exactly as Value::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_detection installed a tracker,
    but no loader ever called record_lazy_load, so n1_stats() stayed at zero however many
    per-parent loads a loop issued. load_lazy now 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_from failed for any model with relationship fields. It serialized the
    whole patch model and then rejected books (or any RelatedMany/Lazy field) 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_dirty ignored expired objects. Every object is expired after commit, so
    modifying an object obtained before the commit and calling mark_dirty was a silent no-op and
    the change never reached the database. An expired object is now persistent and dirty again,
    as with merge.
  • Session::rollback kept rolled-back inserts as persistent. An object INSERTed by a flush
    inside a transaction that was then rolled back stayed Persistent in the identity map although
    its row was gone, so adding 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 e2e add_all scenario.
  • Session::flush set 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 new CapturingConnection in the e2e crate on every
    driver.
  • Session::merge onto 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 no UPDATE and 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_many resolved 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-digit rust_decimal::Decimal
    came back rounded; FrankenSQLite additionally bound decimals as floats. Decimal and numeric
    columns are now declared TEXT on SQLite (exact; SQL-side ordering and arithmetic on them are
    textual) and both SQLite drivers bind Value::Decimal as 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 Cx still 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 the Cx (PostgreSQL only through its connection mutex), and Session::flush
    happily inserted rows under a pre-cancelled context. Every Connection operation in every driver
    now returns Outcome::Cancelled before touching the database when the context is already
    cancelled (sqlmodel_core::cancel_requested), as do Session::flush and Session::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_many returned no children on C SQLite. Children were grouped by a
    hash of the parent key Value that distinguished Int from BigInt; C SQLite reports any
    integer that fits i32 as Int, so a parent looked up by BigInt(2) never matched its rows'
    __parent_pk of Int(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 --workspace failed on sqlmodel-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 expand SELECT *, so every model hydration
    failed with "column not found". Column names for SELECT * / SELECT t.* (single table) and
    RETURNING * now come from PRAGMA table_info.
  • i8/i16/i32 fields rejected in-range integers reported as a wider Value variant.
    FrankenSQLite reports every INTEGER as Value::BigInt; FromValue/TryFrom for the narrower
    types only accepted their own width (C SQLite masked this by reporting Int). Narrowing is now
    range-checked: accepted when the value fits, refused with the offending value otherwise, never
    truncated.
  • #[derive(Model)] silently turned type errors on Option<T> fields into None (it used
    .ok()). Only a NULL value or an absent column now hydrates as None; a value of the wrong type
    is an error.
  • DeleteBuilder and UpdateBuilder ignored the single-table-inheritance discriminator:
    delete!(Manager).filter(...) removed rows of every kind sharing the table. Both builders now fold
    the implicit table.discriminator = value predicate into their WHERE clause (SELECT already did).
  • FrankenSQLite busy/snapshot-conflict errors were not retryable. BusySnapshot and
    SnapshotTooOld map to QueryErrorKind::Serialization, Busy/BusyRecovery to Timeout, so
    Error::is_retryable() is true and retry_transaction retries them instead of failing on the
    first concurrent-writer conflict.
  • MigrationRunner could not work on MySQL: the tracking table used TEXT PRIMARY KEY (MySQL
    requires a key length) and the record/delete statements hard-coded PostgreSQL $n placeholders
    (SQLite only accepted them by treating $1 as a named parameter). Now VARCHAR(255), BIGINT,
    and Dialect::placeholder.
  • SchemaBuilder::create_table for single-table-inheritance children emitted ALTER TABLE ... ADD COLUMN for columns the base model already declares (a child that redeclares name to 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 made cargo doc -D warnings
    fail (bd-o59n).

Changed

  • sqlmodel_schema::Dialect is now the core sqlmodel_core::Dialect (re-exported) instead of a
    second, identical-looking enum. Introspector::new(conn.dialect()) and SchemaBuilder::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 exports Dialect, Related, and RelatedMany (the prelude gains
    Dialect), so models with relationship fields no longer need a sqlmodel_core dependency.
  • sqlmodel-mysql no longer depends on the unmaintained rustls-pemfile (RUSTSEC-2025-0134); PEM
    parsing uses rustls::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 integration job with PostgreSQL 16, MySQL 8.4, and MariaDB 11 services that fails if a
    suite skips; Security job now cargo audit --deny warnings + cargo deny check and gates the
    release build; removed the path-dependency-era git clone of 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::close returning Result is documented as the deliberate exception to the
    Outcome invariant (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.toml enforcement note,
    RCH rch exec note. New crates/sqlmodel-frankensqlite/README.md; crates/sqlmodel-mysql/README.md
    gains a security note on RUSTSEC-2023-0071. SESSION_TODO.md retired in place.