Experimental Rust port of Marmot.
See BENCHMARK_REPORT.md for the Rust SQLite benchmark summary.
The goal is to keep Marmot's source layout:
src/items.rs
src/items.sql
src/registrations/index.rs
src/registrations/index.sql
src/orders.rs
src/orders.sql
and generate direct database code from those colocated SQL files.
The first target is Rust + rusqlite. The useful end state is a shared analyzer with multiple emitters:
SQL files + SQLite schema
|
v
query model
|
+-- Rust / rusqlite
+-- Gleam / sqlight
This is an active Rust port, not a complete replacement for Gleam Marmot yet.
It currently:
- finds module companion SQL files like
src/items.rsplussrc/items.sql - supports multiple
-- func:blocks per companion SQL file - derives generated function names from
-- func:blocks - derives generated module paths from the companion SQL path
- extracts named parameters like
@org_id - prepares each statement with SQLite and records result columns
- infers common parameter and result types from schema metadata, expressions, casts, joins, returning clauses, and insert/update positions
- emits typed direct
rusqlitefunctions usingprepare_cached - requires immutable connections for reads and stronger mutation-capable connections for writes
- lowers source-level SQL parameters to dense positional binds in generated Rust
- runs forward-only SQL migrations, seed files, and database resets through
marmot::migrations,marmot::seeds, andmarmot::reset - supports named database references for multi-database projects
- can enforce configured temporal suffixes and generate checked Rust date/datetime boundary types for those columns
The remaining work is deeper SQL coverage and polish around diagnostics and edge-case inference.
Marmot uses SQLite declared types to build its language-neutral query model. In particular, BOOLEAN and BOOL generate Boolean parameters and result fields even though SQLite stores those values as integers.
SQLite STRICT tables reject BOOLEAN and BOOL. To preserve explicit Boolean semantics with an allowed integer storage type, name the column's canonical 0/1 check constraint boolean:
CREATE TABLE settings (
enabled INTEGER NOT NULL
CONSTRAINT boolean CHECK (enabled IN (0, 1))
) STRICT;Marmot generates enabled as bool. The same named constraint works on ordinary non-strict tables. The marker must be a column-level constraint on an INT or INTEGER column, and its check must use the exact shape CHECK (column IN (0, 1)). Marmot rejects malformed markers during analysis.
An unnamed 0/1 check remains an integer because that shape can represent an index, numeric flag, or two-value domain type. Marmot does not infer Boolean semantics from values, column names, defaults, or unnamed constraints. See ADR 0003.
Inspect discovered queries:
cargo run -- inspect --database path/to/app.db --source-root path/to/srcProject defaults can live in marmot.toml:
[tools.marmot]
database = "path/to/app.db"
source_root = "path/to/src"
output = "path/to/src/generated/sql"
init_sql = "db/marmot_init.sql"
migrations_dir = "db/migrations"
seeds_dir = "db/seeds"Use another config path with --config path/to/marmot.toml. CLI flags override config values.
Multi-database projects can use named references:
[tools.marmot.databases.app]
path = "db/app.sqlite"
source_root = "src/app"
output = "src/app/generated/sql"
init_sql = "db/app_marmot_init.sql"
migrations_dir = "db/migrations/app"
seeds_dir = "db/seeds/app"
[tools.marmot.databases.analytics]
path = "db/analytics.sqlite"
source_root = "src/analytics"
output = "src/analytics/generated/sql"
migrations_dir = "db/migrations/analytics"
seeds_dir = "db/seeds/analytics"Pass --database-name app to target one named database. Without it, generate, migrate, seed, and reset run every named database in sorted name order. Ambient DATABASE_URL does not replace named database paths in that mode. Named references can omit paths; Marmot derives db/NAME.sqlite, src/NAME, src/NAME/generated/sql, db/migrations/NAME, and db/seeds/NAME.
Temporal suffix enforcement is opt-in:
[tools.marmot.temporal]
strict_suffixes = true
datetime_suffixes = ["_at"]
date_suffixes = ["_on"]
datetime_storage = "text_second_utc"
date_storage = "text_ymd"With strict_suffixes = true, columns ending in a configured datetime suffix
must be declared as TEXT and generate temporal::DbDateTime. Columns ending
in a configured date suffix must be declared as TEXT and generate
temporal::DbDate.
If stored text does not match the configured temporal format, generated row
decoding includes the generated field name, bad value, and expected format in
the error. The original rusqlite conversion error remains in the error source
chain.
The storage keys are explicit because they are part of the project contract, but they are not a menu of equally supported backends yet. The only supported values today are:
datetime_storage = "text_second_utc"forYYYY-MM-DD HH:MM:SSUTC datetime textdate_storage = "text_ymd"forYYYY-MM-DDdate text
Unknown storage values are rejected.
init_sql is optional setup for Marmot's analysis connection. Marmot runs the
file after opening SQLite and before reading schema metadata or preparing query
blocks. Named databases inherit [tools.marmot].init_sql unless they set their
own init_sql.
Warning: init_sql is an escape hatch. Marmot does not sandbox it, roll it
back, or check whether it mutates schema or data. It is not a migration system,
and it only runs during inspect and generate, not when your application
starts. Use it for setup the analyzer needs, such as ATTACH, temporary tables,
PRAGMAs, or native SQLite extension loading when your SQLite build and driver
support it.
Generate Rust files:
cargo run -- generate \
--database path/to/app.db \
--source-root path/to/src \
--output path/to/src/generated/sqlA companion SQL file contains named blocks:
-- func: get_item_by_id
select id, name from items where id = @id;
-- func: list_items
select id, name from items order by name;Marmot asks SQLite whether each prepared statement is read-only. Read statements
generate functions that accept &Connection. Anything SQLite reports as a
write, including INSERT, UPDATE, DELETE, write statements with RETURNING,
and DDL, generates a function that accepts impl MutationConnection.
MutationConnection is a sealed generated trait implemented for
&mut Connection and &Transaction. This means an immutable &Connection
cannot call generated mutation SQL:
let mut conn = rusqlite::Connection::open("app.sqlite")?;
queries::create_item(&mut conn, "Stone")?;
let tx = conn.transaction()?;
queries::create_item(&tx, "Broom")?;
tx.commit()?;Existing callers must make standalone write connections mutable and pass
&mut conn. Callers already inside a transaction should pass &transaction.
Read calls stay unchanged. Marmot rejects multi-statement query blocks, so each
generated function has one SQLite access classification.
The generated module mirrors the companion SQL file path under the generated SQL namespace:
src/items.sql -> src/generated/sql/items.rs
src/registrations/index.sql -> src/generated/sql/registrations/index.rs
src/registrations/form.sql -> src/generated/sql/registrations/form.rs
Each -- func: block in one companion file becomes a function in the generated
module for that file.
Check generated files without writing:
cargo run -- generate \
--database path/to/app.db \
--source-root path/to/src \
--output path/to/src/generated/sql \
--checkRun migrations:
cargo run -- migrate \
--database path/to/app.db \
--migrations-dir db/migrationsRun seeds:
cargo run -- seed \
--database path/to/app.db \
--seeds-dir db/seedsReset a database, then run migrations and seeds:
cargo run -- reset \
--database path/to/app.db \
--migrations-dir db/migrations \
--seeds-dir db/seedsThe generator should emit boring concrete code. Runtime speed should come from staying close to hand-written rusqlite: cached prepared statements, positional parameter binds, positional row access, concrete row structs, and no dynamic mapper layer. SQL files can use named parameters for readability, but generated runtime SQL lowers them to dense positional slots so SQLite does not do a name lookup on every call.
SQLx's SQLite analyzer is useful reference material for closing inference gaps. In particular:
- declared SQLite type mapping
- statement description
- result-column nullability
- fallback inference from
EXPLAIN - offline metadata shape
Marmot should borrow ideas carefully, and only copy code when there is a clear reason and the license notice is preserved. SQLx should not be part of generated runtime code.