Skip to main content

keelson_models/
lib.rs

1//! The typed model layer — the runtime the code generator will emit against.
2//!
3//! Nothing is generated yet: this crate is the machinery ([`View`]/[`Table`],
4//! the [`Set`] three-state setter, hooks, preload/then-load plumbing), and the
5//! hand-written users/posts model in `tests/` is the generator's
6//! specification — what it will write, written once by hand and tested end to
7//! end. The call-site shape being served:
8//!
9//! ```ignore
10//! use models::users;
11//!
12//! let adults = users::table().query((
13//!     users::age().gte(21),   // typed: passing &str is a compile error
14//!     select::limit(20),      // Layer 1 mods mix in directly
15//! )).all(&db).await?;
16//!
17//! let u = users::table().insert(users::Setter {
18//!     name: set("Stephen"),
19//!     ..Default::default()
20//! }).one(&db).await?;
21//! ```
22//!
23//! # Where this sits
24//!
25//! Layer 3 of keelson: the runtime a generated model is written against. It
26//! stands on Layer 1 (the dialect crates, through [keelson-core](https://docs.rs/keelson-core)'s
27//! clause traits) and Layer 2 ([keelson-exec](https://docs.rs/keelson-exec), through
28//! `&dyn Executor`), and names no dialect and no driver itself. The models that
29//! use it are written by Layer 4, [keelson-gen](https://docs.rs/keelson-gen) — you can write
30//! one by hand, and the `tests/` in this crate are exactly that. Test data for
31//! those models is [keelson-factory](https://docs.rs/keelson-factory). The whole map is the
32//! [keelson](https://docs.rs/keelson) facade crate.
33//!
34//! # The decisions, recorded
35//!
36//! **One column entry point.** bob splits a column across four generated
37//! surfaces (`ColumnNames`/`Columns`/`SelectWhere`/`Preload`); here
38//! `users::age()` is one [`Column<i32>`] that is the expression, the typed
39//! filter origin and the alias carrier at once. The column's Rust type comes
40//! from `docs/type-mappings.md`; comparisons take `impl Into<T>`, so
41//! `age().gte(21)` compiles and `age().gte("x")` does not (pinned by a
42//! `compile_fail` doctest on [`Column::eq`]).
43//!
44//! **Setter three states by type.** [`Set<T>`] is `Unset | Null | Value(T)`
45//! with `Default = Unset`, built by [`set`]/[`null`]; an unset field does not
46//! appear in the statement at all. `Null` stays representable on `NOT NULL`
47//! columns — the constraint is the engine's to enforce, as it is for raw SQL.
48//!
49//! **Hooks are trait default methods** on the model marker — static dispatch,
50//! no downcasting, the deliberate departure from bob's runtime
51//! type-assertion opt-in. before/after insert/update/delete, after select
52//! (there is no before-select: a query mod at the same call site already *is*
53//! that hook). Every hook receives `&dyn Executor` — the caller's own
54//! executor — so hooks run inside the caller's transaction and cannot end it.
55//! The before-mutation hooks receive the `Setter` mutably.
56//!
57//! **`QueryExtensions`, wired shut.** Core fixed the mechanism with
58//! type-parameter payloads; keelson-exec pinned `Hook` = `ExecHook`; the
59//! remaining two are pinned here, where the row-mapper lives:
60//! `MapperMod` = [`MapperMod<T>`] (same-query preloads decode prefixed columns
61//! into the already-mapped struct) and `Loader` = [`Loader<T>`] (typed over
62//! the model, not `ExecLoader`'s `&[Row]`, because a then-loader's job is to
63//! mutate decoded structs). [`ModelSelect`] implements
64//! `QueryExtensions<ExecHook, Loader<_>, MapperMod<_>>` and its verbs consume
65//! the extensions through that trait.
66//!
67//! **Loaders.** *Preload* is a same-query `LEFT JOIN` for to-one relations:
68//! the generated mod joins, appends prefixed columns through the dialect's
69//! `preload_columns` (kept apart from the caller's projection by
70//! `SelectList`, which was designed for this), and registers a mapper mod
71//! that reads `"user.id"`-style columns back — `Row`'s by-name access is what
72//! makes the prefix trick work. *Then-load* is a second query keyed by the
73//! first's keys, to-one and to-many, attached by
74//! [`attach_to_one`]/[`attach_to_many`].
75//!
76//! **Nested loads are chained values, not paths in a string.** A then-load is
77//! a [`ThenLoad`], and another one hangs off it:
78//! `posts::then_load::user().then(users::then_load::posts())` is
79//! posts → author → the author's posts in three queries, checked by the
80//! compiler (the inner level must load onto *this* level's child model).
81//! One batched `IN` query per level, [`KEY_BATCH`] keys at a time, over the
82//! deduplicated child set — the design and its alternatives are recorded in
83//! `load.rs`.
84//!
85//! **Relation field naming: `rel`, not bob's `r`.** The row struct carries
86//! `post.rel.user` / `user.rel.posts`. `r` is a Go-ism (single-letter
87//! receivers are idiomatic there; in Rust a one-letter public field reads as
88//! an accident), `rel` is greppable, self-describing, and still two
89//! characters shorter than `related`. The generated mod modules follow the
90//! design vocabulary: `posts::preload::user()`, `posts::then_load::user()`.
91//!
92//! **Relation field shape: `Option<Box<Row>>` to-one, `Vec<Row>` to-many.**
93//! A `Rel` field holds the target's whole row, so two models whose to-one
94//! relations point at each other would be a recursive type of infinite size
95//! — `post.rel.user: Option<User>` next to `user.rel.featured_post:
96//! Option<Post>` does not compile. Every to-one field is therefore boxed,
97//! uniformly, rather than only the ones a schema's cycles happen to need:
98//! a field's type must not depend on the rest of the schema graph, or
99//! adding an unrelated foreign key would silently change it. A to-many
100//! field is a `Vec` and already carries its own indirection. Reading is
101//! unaffected (`post.rel.user.as_ref().unwrap().name` derefs through the
102//! `Box`); building one by hand is `Some(Box::new(row))`. The rejected
103//! alternative and the whole argument are recorded in
104//! `keelson-gen/src/emit/model.rs`.
105//!
106//! **View vs Table.** [`View`] is `SELECT`-only and needs no primary key;
107//! [`Table`] adds insert/update/delete and requires one. The mutations are
108//! bounded on `Table`, so calling `insert` on a view model is a compile
109//! error, not a runtime one.
110//!
111//! **Layer 1 interop is structural, not special-cased.** The wrappers
112//! ([`ModelSelect`], [`ModelUpdate`], [`ModelDelete`]) implement every
113//! `Has*` clause trait their statement implements, so the dialect's shared
114//! mods — and any raw `&str` fragment those mods accept — apply to the
115//! wrapper directly, in the same tuple as typed filters. Statement-specific
116//! mods (psql's `select::distinct()`) go through each wrapper's `apply`;
117//! `INSERT` mods ride [`ModelInsert::with`], deferred because the statement
118//! is only built after `before_insert` has seen the setter.
119//!
120//! **Verbs.** `all`/`one`/`optional` on select (`one` means one:
121//! `RowNotFound`/`TooManyRows`, matching the execution layer); `one`/`exec`
122//! on insert; `exec`/`all` on update and delete, where `all` decodes whatever
123//! `RETURNING` the statement carries. The statement itself always goes
124//! through keelson-exec's traced verb funnel (`fetch_rows`/`execute`), so
125//! model queries appear in telemetry like every other query.
126//!
127//! # Per-dialect notes
128//!
129//! The machinery is dialect-generic — it names only keelson-core's `Has*`
130//! traits and keelson-exec's `Executor`. What diverges lives in the generated
131//! (here: hand-written) model:
132//!
133//! - **PostgreSQL** (the demonstration dialect): `RETURNING` carries
134//!   `insert(...).one()` and `update/delete(...).all()`; an all-unset setter
135//!   renders `INSERT INTO t DEFAULT VALUES`.
136//! - **SQLite**: identical shapes (SQLite has `RETURNING` since 3.35 and
137//!   `DEFAULT VALUES`); `timestamptz` columns are `TEXT`, and a column whose
138//!   *default* writes the naive `CURRENT_TIMESTAMP` form is honestly typed
139//!   `NaiveDateTime` by the schema-reading generator.
140//! - **MySQL**: no `RETURNING` anywhere. A generated MySQL model backs
141//!   `insert(...).one()` with `ExecResult::last_insert_id` plus a keyed
142//!   re-`SELECT`, and offers no `update/delete(...).all()`; an all-unset
143//!   setter is spelled `INSERT INTO t () VALUES ()`. The `Table` trait's
144//!   `*_query` seam is per-model precisely so these differences stay inside
145//!   the generator's output.
146
147#![warn(missing_docs)]
148
149mod column;
150mod delegate;
151mod load;
152mod model;
153mod mutate;
154mod select;
155mod set;
156mod table;
157
158pub use column::{Column, Filter};
159pub use load::{IntoLoader, KEY_BATCH, ThenLoad, attach_to_many, attach_to_one};
160pub use model::{Table, View};
161pub use mutate::{ModelDelete, ModelInsert, ModelUpdate};
162pub use select::{Loader, MapperMod, ModelSelect, hook, loader, mapper_mod};
163pub use set::{Set, null, set};
164pub use table::ModelTable;