Expand description
The typed model layer — the runtime the code generator will emit against.
Nothing is generated yet: this crate is the machinery (View/Table,
the Set three-state setter, hooks, preload/then-load plumbing), and the
hand-written users/posts model in tests/ is the generator’s
specification — what it will write, written once by hand and tested end to
end. The call-site shape being served:
use models::users;
let adults = users::table().query((
users::age().gte(21), // typed: passing &str is a compile error
select::limit(20), // Layer 1 mods mix in directly
)).all(&db).await?;
let u = users::table().insert(users::Setter {
name: set("Stephen"),
..Default::default()
}).one(&db).await?;§Where this sits
Layer 3 of keelson: the runtime a generated model is written against. It
stands on Layer 1 (the dialect crates, through keelson-core’s
clause traits) and Layer 2 (keelson-exec, through
&dyn Executor), and names no dialect and no driver itself. The models that
use it are written by Layer 4, keelson-gen — you can write
one by hand, and the tests/ in this crate are exactly that. Test data for
those models is keelson-factory. The whole map is the
keelson facade crate.
§The decisions, recorded
One column entry point. bob splits a column across four generated
surfaces (ColumnNames/Columns/SelectWhere/Preload); here
users::age() is one Column<i32> that is the expression, the typed
filter origin and the alias carrier at once. The column’s Rust type comes
from docs/type-mappings.md; comparisons take impl Into<T>, so
age().gte(21) compiles and age().gte("x") does not (pinned by a
compile_fail doctest on Column::eq).
Setter three states by type. Set<T> is Unset | Null | Value(T)
with Default = Unset, built by set/null; an unset field does not
appear in the statement at all. Null stays representable on NOT NULL
columns — the constraint is the engine’s to enforce, as it is for raw SQL.
Hooks are trait default methods on the model marker — static dispatch,
no downcasting, the deliberate departure from bob’s runtime
type-assertion opt-in. before/after insert/update/delete, after select
(there is no before-select: a query mod at the same call site already is
that hook). Every hook receives &dyn Executor — the caller’s own
executor — so hooks run inside the caller’s transaction and cannot end it.
The before-mutation hooks receive the Setter mutably.
QueryExtensions, wired shut. Core fixed the mechanism with
type-parameter payloads; keelson-exec pinned Hook = ExecHook; the
remaining two are pinned here, where the row-mapper lives:
MapperMod = MapperMod<T> (same-query preloads decode prefixed columns
into the already-mapped struct) and Loader = Loader<T> (typed over
the model, not ExecLoader’s &[Row], because a then-loader’s job is to
mutate decoded structs). ModelSelect implements
QueryExtensions<ExecHook, Loader<_>, MapperMod<_>> and its verbs consume
the extensions through that trait.
Loaders. Preload is a same-query LEFT JOIN for to-one relations:
the generated mod joins, appends prefixed columns through the dialect’s
preload_columns (kept apart from the caller’s projection by
SelectList, which was designed for this), and registers a mapper mod
that reads "user.id"-style columns back — Row’s by-name access is what
makes the prefix trick work. Then-load is a second query keyed by the
first’s keys, to-one and to-many, attached by
attach_to_one/attach_to_many.
Nested loads are chained values, not paths in a string. A then-load is
a ThenLoad, and another one hangs off it:
posts::then_load::user().then(users::then_load::posts()) is
posts → author → the author’s posts in three queries, checked by the
compiler (the inner level must load onto this level’s child model).
One batched IN query per level, KEY_BATCH keys at a time, over the
deduplicated child set — the design and its alternatives are recorded in
load.rs.
Relation field naming: rel, not bob’s r. The row struct carries
post.rel.user / user.rel.posts. r is a Go-ism (single-letter
receivers are idiomatic there; in Rust a one-letter public field reads as
an accident), rel is greppable, self-describing, and still two
characters shorter than related. The generated mod modules follow the
design vocabulary: posts::preload::user(), posts::then_load::user().
Relation field shape: Option<Box<Row>> to-one, Vec<Row> to-many.
A Rel field holds the target’s whole row, so two models whose to-one
relations point at each other would be a recursive type of infinite size
— post.rel.user: Option<User> next to user.rel.featured_post: Option<Post> does not compile. Every to-one field is therefore boxed,
uniformly, rather than only the ones a schema’s cycles happen to need:
a field’s type must not depend on the rest of the schema graph, or
adding an unrelated foreign key would silently change it. A to-many
field is a Vec and already carries its own indirection. Reading is
unaffected (post.rel.user.as_ref().unwrap().name derefs through the
Box); building one by hand is Some(Box::new(row)). The rejected
alternative and the whole argument are recorded in
keelson-gen/src/emit/model.rs.
View vs Table. View is SELECT-only and needs no primary key;
Table adds insert/update/delete and requires one. The mutations are
bounded on Table, so calling insert on a view model is a compile
error, not a runtime one.
Layer 1 interop is structural, not special-cased. The wrappers
(ModelSelect, ModelUpdate, ModelDelete) implement every
Has* clause trait their statement implements, so the dialect’s shared
mods — and any raw &str fragment those mods accept — apply to the
wrapper directly, in the same tuple as typed filters. Statement-specific
mods (psql’s select::distinct()) go through each wrapper’s apply;
INSERT mods ride ModelInsert::with, deferred because the statement
is only built after before_insert has seen the setter.
Verbs. all/one/optional on select (one means one:
RowNotFound/TooManyRows, matching the execution layer); one/exec
on insert; exec/all on update and delete, where all decodes whatever
RETURNING the statement carries. The statement itself always goes
through keelson-exec’s traced verb funnel (fetch_rows/execute), so
model queries appear in telemetry like every other query.
§Per-dialect notes
The machinery is dialect-generic — it names only keelson-core’s Has*
traits and keelson-exec’s Executor. What diverges lives in the generated
(here: hand-written) model:
- PostgreSQL (the demonstration dialect):
RETURNINGcarriesinsert(...).one()andupdate/delete(...).all(); an all-unset setter rendersINSERT INTO t DEFAULT VALUES. - SQLite: identical shapes (SQLite has
RETURNINGsince 3.35 andDEFAULT VALUES);timestamptzcolumns areTEXT, and a column whose default writes the naiveCURRENT_TIMESTAMPform is honestly typedNaiveDateTimeby the schema-reading generator. - MySQL: no
RETURNINGanywhere. A generated MySQL model backsinsert(...).one()withExecResult::last_insert_idplus a keyed re-SELECT, and offers noupdate/delete(...).all(); an all-unset setter is spelledINSERT INTO t () VALUES (). TheTabletrait’s*_queryseam is per-model precisely so these differences stay inside the generator’s output.
Structs§
- Column
- A model’s column: the one entry point for everything a column is used for.
- Filter
- A typed condition on its way to a
WHERE. - Model
Delete - A pending model
DELETE. - Model
Insert - A pending model
INSERT: the three-state setter, held unbuilt soTable::before_insertcan still rewrite it once an executor is in hand. - Model
Select - A model
SELECT: the dialect statement plus the extension payloads the query carries — hooks, preload mapper mods, then-loaders. - Model
Table - The model’s entry point — what
users::table()(or, for aSELECT-only model,reports::view()) returns. - Model
Update - A pending model
UPDATE: the statement (which filters and mods have already landed on) plus the setter, joined together only at verb time soTable::before_updatesees the setter first. - Then
Load - One level of a load path: fetch
Cfor a set ofP, keyed, batched and deduplicated — plus the levels hanging off it.
Enums§
- Set
- One field of a generated
Setter: unset,NULL, or a value — three states, distinguished by type.
Constants§
- KEY_
BATCH - How many keys one level’s keyed query may carry.
Traits§
- Into
Loader - Anything that can act as one level of a load path over
T. - Table
- A writable model: a
Viewwith a primary key and the three mutations. - View
- A readable model: enough to
SELECTand map rows. No primary key required — a database view, a reporting projection, a read-only slice of a table are allViews.Tableadds the mutations.
Functions§
- attach_
to_ many - Attach a to-many relation: each parent gets every child whose key matches.
- attach_
to_ one - Attach a to-one relation: each parent gets the child whose key matches, or
None. Children are cloned only where several parents share one. - hook
- Wrap a closure as an
ExecHook. The named-function-plus-Box::pinshape generated code uses: - loader
- Wrap a closure as a
Loader. - mapper_
mod - Wrap a closure as a
MapperMod. - null
- An explicit SQL
NULL—Set::Null, spelled the way the sketch spells it. - set
- A set field:
set("Stephen")isSet::Value("Stephen".into()).
Type Aliases§
- Loader
- The then-load payload, pinned: runs after the rows are mapped, with the
caller’s executor and the decoded models, so a second query can be
keyed by the first’s keys and its results attached to the
relfields. - Mapper
Mod - The mapper-mod payload, pinned: after the base row struct is decoded, each
mapper mod reads more of the same
Rowinto the struct — the prefixed preload columns a same-queryLEFT JOINadded.