Architecture
Besu is built as four independent layers rather than one crate with feature flags. The split is not decoration: each seam exists because something useful becomes possible when a particular dependency is absent.
The crate graph
Every arrow points from a crate to something it depends on. There are no cycles.
besu-coredepends on nothing in the workspace. It has no query API, no database driver and no proc macro. It can build and render SQL on its own.besudepends onbesu-corefor the vocabulary, onbesu-schemafor migrations, and onbesu-macrosfor the derive. It does not depend on any database.- Each driver depends on
besu-core,besu-schemaandbesu. The dependency onbesuis only forSchemaDriver; the execution path goes straight tobesu-core. besu-cliis the only place that knows about all three backends at once, because a CLI has to dispatch on a URL at runtime.
If you want to add a dependency from besu-core to anything else in the workspace, the
abstraction is wrong. Say so in an issue rather than adding the edge.
Why the seams are where they are
Dialect versus driver
A Dialect is a compile-time tag that knows how to spell SQL: placeholders, quoting, type
names, capability flags. It can render a complete statement with no connection. A Driver
only moves bytes to a server and rows back. Everything about what SQL to run lives in the
dialect; everything about how to reach the database lives in the driver.
That split is what makes the following possible:
No pool, no connection string, no server — and the SQL is the real SQL, not an approximation produced by a second implementation.
Executor versus Driver
A builder is generic over an Executor, not over a driver. &Database<Dr> and
&mut Transaction<'_, Dr> both implement Executor. That is why the same Select value can
run on a pool, inside a transaction, or against DryDriver without a second query API:
The dialect is not a separate type parameter on Executor; it is a projection of the
driver (DialectOf<E>), so an executor's dialect and driver can never disagree.
Schema as a separate crate
The schema IR is shared by three producers (the derive, introspection, a JSON snapshot) and
two consumers (diff, Ddl). Keeping it out of besu means the CLI can diff a snapshot
against a live database without linking the query builders, and besu can expose push
without depending on the CLI.
Macros generate metadata, not queries
#[derive(Table)] emits a plain struct, a const handle, an insert struct and a
&'static TableDef. It does not generate per-query code and it does not run at query time.
The hot path contains no proc-macro output beyond ordinary structs and impls.
How a query becomes SQL
Every builder is a plain data structure with a type parameter or two for checking. Select,
for example, holds a SelectQuery:
Each builder method pushes into one of those vectors and returns Self. Nothing is rendered
until the end.
Step by step, for a select:
- Builder.
db.select(users)createsSelectwithfrom = T::DEFand an emptySelectQuery..filter(users.age.gt(18))converts the condition to anExprKindand pushes it. The type parametersMandRtrack the row set and the result type; they are erased at runtime. - AST. The filter is an
ExprKind::Binary { op: ">", left: Column { table: "users", column: "age" }, right: Value(Int(18)) }.ExprKindis a closed, dialect-independent tree. There is no typedExprleft at this point: the types did their job at the call site and are gone. - Render.
SelectQuery::build::<D>()walks the fields in SQL order and writes into aSql<D>.push_rawappends text,push_identquotes an identifier through the dialect,bindwrites a placeholder and appends the value.ExprKind::renderparenthesises every binary node, which is why the output is("users"."age" > $1)rather than relying on precedence. - Finish.
Sql::finish()returns the statement if no error was recorded, or the first error if one was. Capability failures —RETURNINGon MySQL,FULL OUTER JOINon MySQL,FOR UPDATEon SQLite — are recorded here, before the driver sees anything. - Execute.
Executor::queryhands the renderedSqltoDriver::query, which converts each already-bound value into the driver's argument type and runs it.executeis the same path for statements that return no rows. - Decode. For a query,
Decode::decodewalks the row with ausizeoffset. A model consumes one column per field in declaration order; a tuple consumes one per element;Option<T>peeks forNULLbefore deciding.
The same pipeline serves a RawQuery: sql! produces a Fragment, Fragment::render
rewrites ? placeholders and produces the same Sql<D>, and the same executor runs it.
The error model
Sql<D> carries Option<Error> and keeps only the first error recorded. Later failures
are almost always a consequence of the first, and a single clear message beats a cascade. A
builder therefore does not return Result from every method; it records and keeps going, and
finish() is the single place that decides whether the statement is usable.
This is why to_sql() returns a Sql whose .error() may be set even though the call itself
returned Ok: inspection wants the partial text, execution wants the error. Database
execution always calls finish().
The allocation story
The design goal is: one SQL string per query, one encode per parameter, one allocation per row of result, and nothing per row of work.
cargo bench -p besu measures the builder. It uses a dry database and a hand-written
dialect, so no server is needed and the numbers are exactly the cost Besu adds on top of a
driver. On a Ryzen-class machine, release build:
Those numbers include to_sql, and therefore include the query clone described above. A
query that runs moves its values instead and pays less. The bulk insert is dominated by
cloning a hundred pre-built rows for the same reason; executing one moves them.
Three honest caveats:
- The table handle is not literally zero-sized: each
Columnstores its name as a&'static str, which is 16 bytes on a 64-bit target. A five-column handle is 80 bytes, allCopy. It is small and free to pass around, not a ZST. - Storing operands inline makes an
ExprKindnode larger (about 104 bytes rather than 32) so that aWHEREclause costs no allocations. A query holds tens of nodes, not millions, so the trade is a few hundred bytes of vector space against two allocations per condition. - The capacity estimate is an estimate. It errs high, so a statement can carry a few hundred
spare bytes of
Stringcapacity; it is sized to avoid copies, not to be exact.
What is deliberately absent is just as important: there is no query cache, no lazy loading, no identity map, no N+1 machinery and no hidden second statement. A query is a value, and running it does exactly what its SQL says.
Threading and lifetimes
Builders borrow the executor for 'a: Select<'a, &Database<Dr>, M, R>. A query cannot
outlive the database it will run on, and a transaction cannot outlive the borrow of the
database. IntoFuture boxes a future with a Send bound when the executor is Send, which
is what lets queries run on a multi-threaded runtime. Driver is Send + Sync + 'static, so
a pool can be cloned into as many tasks as you like.
Compiling for wasm32
The query builder compiles and runs on wasm32-unknown-unknown. A web front end can build
the same typed query the server would build and render the exact SQL it will send. Nothing
in that path needs a socket, a filesystem, an async runtime or a clock: it is alloc and
arithmetic. The intended use is generating SQL on the client for a server to execute.
The boundary is enforced, not assumed: the wasm CI job checks besu-core and besu for
this target and besu/tests/dry.rs asserts the rendered SQL on every platform. There is no
wasm feature flag, because nothing in the builder needed one; a feature nobody has to
enable cannot silently rot.
What still traps
std compiles for this target, but parts of it do not work, and the failure is a trap at
run time rather than an error at build time:
std::time::Instant::now()panics. Thetracingfeature compiles, butbesu::logged(..)reads the clock once a subscriber has thebesu::querytarget enabled, so the wrapper traps in a browser exactly when logging is switched on. With no subscriber it never touches the clock.chrono::Utc::now()panics as well, unlesschrono/wasmbindis enabled (which pulls inwasm-bindgenand its loader). Besu never reads the clock:now()in an expression writes the SQLCURRENT_TIMESTAMPand the database evaluates it, and a timestamp that came from JavaScript is just aValue::TimestampTz.Send + Syncbounds still compile. A wasm module is single-threaded, so the bounds are vacuous on this target, not wrong.
Decoding is the other half of the boundary: Decode is implemented by each driver, because
pulling a value out of a row is database-specific. A browser has no rows and no Decode, which
is exactly why dry::<D>() is the honest executor there: it renders, and attempting to run the
statement returns an error.
What each layer is allowed to know
The studio
besu studio is the only interactive consumer of the layers above, and it is a subcommand of
the CLI: the server is a library (besu-studio) that the binary serves, so the web stack stays
out of besu and besu-core while shipping as one command. The CLI depends on besu-studio;
nothing in the library depends on the CLI.
It uses only the dynamic APIs. AnyDatabase::introspect supplies GET /api/schema; the row
endpoint matches on the backend, asks the driver for each cell as text — the driver is the
only layer that knows how a row is laid out — projects NULL through Driver::is_null, and
uses the introspected column type to restore numbers, booleans and JSON. The page is one
HTML file inlined into the binary, with no build step and no CDN.
Security posture
There is no authentication, and the query endpoint runs any single statement, including one that writes. That is deliberate for a development tool and unacceptable anywhere else, so:
- the binary binds
127.0.0.1unless--allow-remoteis passed, and prints what that means when it is; - a table name in the row URL is checked against the introspected schema rather than
interpolated into SQL, and the statement is built through
Sql, which quotes identifiers the dialect's own way; - the query endpoint refuses anything the migration splitter does not report as exactly one statement, so a pasted script is refused rather than half-run.
The statement is a Fragment, the same type sql! produces, so a bare ? is a parameter
placeholder and ?? is a literal question mark. There are no parameters to bind from the
page, which is another reason the SQL box is for development only.
A studio fit for anything but a development machine would need authentication, an explicit read-only mode and a write policy that is off by default. None of those are here.
Gotchas
- A dialect can be total and still be wrong.
SUPPORTS_*is a promise the dialect makes; if it claimsRETURNINGon a database that lacks it, the failure moves to the server. to_sql()is not a dry run of execution. It renders; it does not check the connection, the schema or the parameter count against a server.- The type parameters on a builder are not erased for the compiler.
MandRare phantom, but they still participate in inference; a long join chain produces deeply nested tuple types, and very long chains can hit trait-resolution limits. Sqlis generic over the dialect, never the driver. That is deliberate: it is the type that lets a test suite assert on SQL with no database present.
