Expand description
The code generator: a live schema in, readable model .rs files out.
keelson-gen is an independent CLI emitting .rs files (the bob/sqlc
stance), not a proc macro: generated code is meant to be read, diffed and
stepped through. The pipeline is introspect → resolve → emit:
introspect::introspect turns a connection string into a plain
schema::Schema IR, resolve applies the whole config::Config
(filters, renames, relations, hooks, the type map) so emission is
decision-free, and emit renders TokenStreams built with quote,
formatted with prettyplease. What it emits is exactly what the
hand-written spec models in keelson-models/tests/spec_psql.rs /
spec_sqlite.rs / spec_mysql.rs fix — those files are the
authoritative specification, and this crate’s tests run the generated code
through the same assertions. The factory half is specified the same way,
by keelson-factory/tests/spec_*.rs.
The main entry point is run; generate returns the files without
writing them, and generate_from_schema skips introspection for
callers (and tests) that already hold an IR.
The generator has a second, independent output: queries turns
hand-written .sql files into typed modules (the sqlc-shaped half),
keyed off the [queries] section of the same config and reading the same
schema::Schema. Its docs carry the nullability decision table and the
two-faces design; nothing in the model pipeline depends on it.
§Where this sits
Layer 4 of keelson, and the only crate here you run rather than link: it is
a CLI (cargo install keelson-gen) that reads a live schema and writes .rs
files against keelson-models and
keelson-factory, in the dialect of
keelson-psql, keelson-mysql or
keelson-sqlite. It emits no DDL and tracks no migration
history — point it at the database your migration tool produced. The whole
map is the keelson facade crate.
§The decisions, recorded
Introspection: direct catalog queries, not sea-schema. sea-schema
(SeaORM’s introspection crate) was evaluated against querying
pg_catalog / sqlite_master directly, per dialect:
- Weight: sea-schema brings sea-query plus an async runtime coupling
(its discovery API is async over sqlx), where this crate otherwise
needs only the sync
rusqliteandpostgresclients already in the workspace for keelson-sqlcheck’s live judges. A dependency that heavy must earn its keep, and here it cannot: - Lossy middle layer: sea-schema normalises types into its own
ColumnTypeenum, which would have to be translated back into type names to feeddocs/type-mappings.md’s table and the config’sdb_typematchers. Queryingformat_type(...)/ the declared SQLite type text hands the type map its keys verbatim. - Determinism: owning the catalog queries means owning every
ORDER BY; byte-identical output is a contract, not a hope.
So: SQLite via sqlite_master + pragma_table_info /
pragma_foreign_key_list (rusqlite), PostgreSQL via pg_catalog +
format_type (postgres), MySQL via information_schema + COLUMN_TYPE
(mysql) — the same pattern, and the same reason for taking each type
spelling verbatim (COLUMN_TYPE, not DATA_TYPE, because only the
former distinguishes tinyint(1) from tinyint).
Schema provenance is the user’s migration flow. keelson-gen takes a connection string and reads what is there; it neither parses migration files nor tracks schema history. Point it at the database your migrations produced.
Determinism. Same schema + same config ⇒ byte-identical files: tables sorted by name, columns in catalog order, foreign keys sorted by column list, one bundled formatter (prettyplease — the user’s rustfmt version never touches the output), a fixed header with no timestamps. Pinned by a generate-twice test.
Generated files are never hand-edited (the sqlc stance), and hooks
live outside them. bob regenerates wholesale and so does keelson-gen:
every emitted file starts with @generated … DO NOT EDIT. The spec
models show application-written hooks inside the Table impl, which a
wholesale regenerator would clobber — the recorded resolution is
config-declared hook delegation: [tables.users] hooks = ["before_insert", …] makes the generator emit an override of exactly
those trait methods, each a one-line delegation to
<hooks.module>::users::before_insert(…) — a module the application
writes by hand, outside the generated directory. Unlisted hooks stay
trait defaults (nothing is emitted, per the models crate’s design), a
listed-but-unwritten hook is a compile error naming the missing path,
and regeneration can never eat application code because application code
never lives in a generated file.
Overrides must bind, at one named line. Every column whose type came
from [types.map] or [[types.override]] emits
const _: () = keelson_exec::assert_bind::<T>(); under a doc comment
naming the column — a replacement type that cannot bind fails to compile
on that line, not in an inference swamp (the contract
keelson_exec::Bind was built for).
Dialects. PostgreSQL and SQLite are identical in shape (both have
RETURNING and DEFAULT VALUES); the machinery differences live
entirely in which crate the statements come from. MySQL is deliberately
not a copy of that path, because it has no RETURNING anywhere: its
Table body writes a plain INSERT (an all-unset setter being MySQL’s
VALUES ()), and the model hands out its marker from table()
rather than ModelTable, with inherent verbs that can be honoured —
insert(…).one() inserts and then re-SELECTs by key (the setter’s own
primary key, else last_insert_id), update/delete offer exec and
no all. The read-back is two statements and says so, in the generated
docs and in keelson-models/tests/spec_mysql.rs, which is the
specification this emits.
Every to-one relation field is Option<Box<Row>>. A generated Rel
holds the target’s whole row, so two models that reference each other
to-one hold each other by value — which is a recursive type of infinite
size, a compile error in the emitted code that no user of this generator
could work around. Two base tables with mutual single-column foreign keys
are enough to produce it. Boxing is therefore unconditional rather than
applied only to the edges that close a cycle: a field’s type must not be
a function of the whole schema graph, or adding an unrelated foreign key
would change an existing struct and break code at a distance. To-many
fields stay Vec<Row>, which carries its own indirection. The argument
is recorded in full in emit/model.rs.
Factories are opt-in output, not a second generator. [output] factories = true adds one factories.rs — a keelson-factory template
module per writable table, exactly as keelson-factory/tests/spec_*.rs
specifies, writing through the model’s insert path so hooks fire. It is
off by default: a production crate has no reason to carry test-data
machinery it never calls. The per-column default rule (unique columns
take sequences, defaulted columns are omitted, the rest are faked) is
recorded in emit/factory.rs.
Views are configured, not inferred (docs/views.md). A view has no
foreign keys and usually no primary key, so the catalog cannot say how it
relates to anything or what identifies a row of it. Neither is guessed:
a relation touching a view is a [[relationships]] declaration carrying
an explicit cardinality, validated against the introspected schema so a
typo is a generation-time error naming the TOML key; and identity is
simply not required for reads, because the loaders group by the declared
join column rather than by a row identity. A keyless view therefore
holds and is the target of relations while getting less than a table
— no Pk, no Setter, no INSERT/UPDATE/DELETE, no keyed read-back
on MySQL, no factory. It earns the write surface only by declaring
[tables.<name>] key, and only when the engine says writes reach it,
which the three engines decide differently (PostgreSQL’s
pg_relation_is_updatable, MySQL’s IS_UPDATABLE, SQLite’s INSTEAD OF
triggers).
Recorded limitations. Multi-column foreign keys are introspected but
emit no relation (composite keys still work as Pk tuples); a base table
whose primary key falls to the column filters demotes to a view model;
[output] factories = true cannot cover a writable view and says so.
Re-exports§
pub use config::Config;
Modules§
- config
- The TOML configuration — bob’s gen config inventory, ported and adapted.
- introspect
- Introspection: a connection string in, a
Schemaout. - queries
- Layer 4: hand-written SQL in, typed Rust out — and the same query usable as a mod.
- schema
- The introspected schema — the generator’s intermediate representation.
Structs§
- Resolved
Type - A resolved column type: the Rust path to emit, and whether it came from
configuration (and so needs its
assert_bindline).
Enums§
- GenError
- Everything that can go wrong between a connection string and the emitted
files. One enum, no
anyhow: callers (the CLI, tests, build scripts) match on the kind.
Functions§
- generate
- Introspect the configured database and render every generated file as
(file name, contents),mod.rsfirst — without touching the filesystem. - generate_
from_ schema - Render from an already-held
schema::Schema— the seam tests and build scripts use to skip the database. - run
- The documented main entry: introspect, render, write to the configured
output directory. This is what the
keelson-genbinary calls. - write_
files - Write rendered files into
out_dir, creating it if needed. Returns the written paths. Stale files from earlier runs are removed only if they carry the@generatedheader, so a hand-written file dropped into the directory by mistake is never deleted silently.
Type Aliases§
- Result
- The crate-wide result.