Writing a driver
A driver is two things: a Dialect, which knows how a database spells SQL, and a Driver,
which knows how to run it. Keeping them apart is what lets Besu render a complete statement
with no database attached — the tests and the CLI depend on that.
besu-sqlite is the smallest complete driver; besu-postgres and besu-mysql follow the
same shape. Read one of them alongside this page.
The traits
Driver, Dialect and Decode have no dependency on the query builders. A driver crate
depends on besu-core, on besu-schema for DDL, and on besu only to implement
SchemaDriver.
Dialect
A dialect is a type, not a value: it has no state and exactly one instance. Every method must
be total with respect to expression: it never fails because it cannot spell a construct.
bind is the exception in form — it returns Result so a value the database cannot represent
(such as a u64 above i64::MAX on SQLite) is an encode error rather than a truncation. When
the database genuinely cannot express a construct (MySQL has no RETURNING), the capability
constants let the builder reject the query with a clear error before it is sent.
Placeholder is Question, QuestionNumbered or Dollar. UpsertStyle is OnConflict or
OnDuplicateKey.
type_name is used by CAST and by DDL. It must return the database's native spelling:
BIGINT, DOUBLE PRECISION, TIMESTAMPTZ, and so on.
A real dialect
The SQLite one is small enough to read in full:
SQLite's bind rejects a u64 that does not fit in an i64, because the database has no type
that could hold it. That is the shape of a real encode check: return Error::encode(value, reason) rather than truncating.
Driver
Erroris the only error type callers see. A build error, an encode failure and a decode failure all arrive throughSelf::Error, so a query has one?type.Rowis whatever the database's result API hands back;is_nullandDecodeare the only things that inspect it.Outputis what a row-less statement reports: affected rows, a command tag,().errorconverts abesu_core::Errorinto the driver's error. SQLite wraps it insqlx::Error::Protocol.query/executetake&self, so a pool can be cloned freely and a connection can be shared. They returnimpl Future + Send, which lets a query be awaited from aSendcontext.
The Sql passed in is already rendered for Self::Dialect: sql.sql() is the text and
sql.args() is the slice of bound values, already converted by Dialect::bind. A driver
never sees an unbound value. If you want the same checks the builders use, call
sql.render() and handle the error before executing.
is_null must not fail: an out-of-range index reports true. That is what makes an
outer-joined row decode as None instead of erroring when a driver hands back a short row.
row_len is part of the row-access contract even though the current Decode path walks the
offset itself. Implement it as the real column count.
Decode
The contract: start at *offset, decode Self::COLUMNS columns, and advance *offset by
exactly that many. A scalar consumes one column; a tuple consumes the sum of its elements; the
#[derive(Table)] model consumes one per field.
Two implementations are provided for you:
Option<T>peeks atT::COLUMNScolumns withD::is_null, returnsNoneand advances if they are allNULL, and otherwise decodesT.- Tuples decode each element in order.
Everything else is database-specific and belongs in the driver. The SQLite driver, for
example, has one impl Decode<Sqlx> per primitive, generated by a small macro around this
helper:
The macro expands to an impl like this for i64:
A decode failure should become D::error(Error::decode(index, reason)). The index in the
error is the column that failed, which is the only useful location a row decoder can report.
A model does not need a hand-written Decode at all: the derive emits one with a bound per
field, so the model is decodable exactly when every field type is.
TransactionDriver
Transactions are a separate trait because not every database can offer them and because a
read-only driver should not be forced to implement them. commit and rollback consume the
transaction handle; that is what makes a double commit impossible.
The executor impls are what connect this to the builders:
A query builder is generic over Executor, so the same builder runs on a pool, on a
transaction, or on nothing at all (DryDriver).
SchemaDriver and Ddl
Migrations and the CLI need two more things: a way to read the live schema, and a way to render DDL.
Ddl has working defaults for everything except name, quote, type_name and
column_sql:
Three defaults matter in practice:
alter_columnis deliberately blunt: drop the old column and add the new one. Override it with a realALTER(MySQL does this withMODIFY COLUMN), or setcan_alter_column() == falseand require an explicit migration (SQLite does this).drop_indexfor MySQL needs the table name, so the method receives it even though SQLite and PostgreSQL ignore it.column_sqlis where each database's auto-increment spelling lives. PostgreSQL usesGENERATED BY DEFAULT AS IDENTITY, SQLite requiresINTEGER PRIMARY KEY AUTOINCREMENTinline, MySQL usesAUTO_INCREMENT.INLINE_INDEXEStellscreate_schemawhethercreate_tablealready wrote the secondary indexes. MySQL sets it because aTEXT/BLOBindex needs a prefix length, which only the column types can supply.CREATE_INDEX_NEEDS_TYPESis declared for the same reason but is not yet read anywhere:create_indexreceives only the table name and the index, so a dialect that needs the column types has no hook for them yet.changedelegates todefault_change. Overridechangeonly for a dialect that handles a subset of changes differently; callingself.change(..)from inside it recurses forever.
create_schema creates the tables in dependency order and then their indexes, so a foreign
key can reference a table that has not been created yet.
Introspection must produce the same normalised IR that diff expects: canonical TypeDefs,
normalised defaults, primary-key-first columns, sorted indexes and foreign keys. If it does
not, push will report a change on every run. The SQLite driver reads PRAGMA table_info,
PRAGMA index_list and PRAGMA foreign_key_list; MySQL reads information_schema.
normalise_type is the dialect's answer to "which canonical types does the database store
identically?". push passes it to diff_with, so a database with one integer type must fold
every integer width, and a database without unsigned integers must fold each UInt onto the
signed type that holds it. The default is the identity, which is right for a database that
keeps the declared types faithfully.
A worked example of the seam
besu::logged(driver) wraps any driver and reports every statement through tracing, with
timing and a slow-query warning. It implements Driver, TransactionDriver and SchemaDriver
by delegation, which is the whole point of the split: a middleware is a plain wrapper and the
query layer never learns it exists. Read besu/src/logged.rs after besu-sqlite — it is the
smallest way to see what a driver actually has to provide, and the cost design (check
tracing::enabled! before formatting anything) is worth copying.
execute_statements
SchemaDriver has one method beyond introspect: execute_statements, which runs a list of
schema statements. The default runs them one at a time and is right for almost every database.
SQLite overrides it because its table rebuild toggles PRAGMA foreign_keys, a per-connection
setting — through a pool the DROP TABLE would land on a connection that still enforces
foreign keys and cascade into child rows. Override it when your statements only make sense
together.
Choosing a driver at runtime
A query builder is compile-time over a dialect on purpose: the SQL a query produces depends on the dialect, so one query cannot be shared between two databases that spell it differently. What can be dynamic is everything around it.
besu-any provides AnyDatabase, chosen from a connection string:
That covers migrations, schema push and raw statements, which is what a config-driven binary actually needs. For typed queries, match on the variant — the dialect comes back as soon as you name one — and write the query once as a generic function:
Type erasure is deliberately not offered: Driver has associated types and returns impl Future, so erasing it means boxed rows, boxed futures and a runtime decoder — an allocation
per row to solve a problem the match already solves.
Testing a new driver
- SQL without a database.
besu::dry::<MyDialect>()builds every query and never executes it. Assert onsql.sql()andsql.args(). This catches placeholder and quoting mistakes before a server exists. - Round-trip the schema. Create a database,
pushthe declared tables, then assert that a secondpushis empty and thatintrospectreports the columns, indexes and foreign keys you expect.besu-sqlite/tests/query.rsdoes exactly this inschema_push_converges. - Decode every supported type. The
Decodeimpls are where a driver is most likely to be subtly wrong: an unsigned integer that wraps, a timestamp that loses its timezone, aNULLthat becomes a zero. - Test the capability errors. Ask for
RETURNINGon a database without it, orFOR UPDATEon SQLite; the error should be the one you intended, not a database syntax error.
Gotchas
bindis per value, not per statement. ReturnError::encodewhen a single value does not fit; do not silently clamp.write_placeholder's index is zero based. PostgreSQL adds one to spell$1.type_nameserves bothCASTand DDL. MySQL'sCASTaccepts a much smaller set of target names than its DDL does, so a type map can be correct forCREATE TABLEand still produce an invalidCAST.is_nullmust be infallible. Returntruefor an index that is out of range rather than an error;Option<T>relies on it.row_lenandDecode::COLUMNSmust agree. A mismatch shows up as a decode that reads the wrong column, not as a clean error.MAX_PARAMSis currently enforced for bulk inserts. Individual statements are not pre-checked, so a query with a hugeINlist still depends on the driver to reject it.ValueandErrorare#[non_exhaustive]. Match with a_arm; new variants are additive.- A driver's error type is part of its public API. Wrapping
besu_core::Errorin the driver's own error keeps?usable; returning a boxed error loses the ability to match on encode versus decode failures.
