Skip to main content

keelson_gen/
lib.rs

1//! The code generator: a live schema in, readable model `.rs` files out.
2//!
3//! keelson-gen is an **independent CLI emitting `.rs` files** (the bob/sqlc
4//! stance), not a proc macro: generated code is meant to be read, diffed and
5//! stepped through. The pipeline is introspect → resolve → emit:
6//! [`introspect::introspect`] turns a connection string into a plain
7//! [`schema::Schema`] IR, `resolve` applies the whole [`config::Config`]
8//! (filters, renames, relations, hooks, the type map) so emission is
9//! decision-free, and `emit` renders TokenStreams built with `quote`,
10//! formatted with `prettyplease`. What it emits is exactly what the
11//! hand-written spec models in `keelson-models/tests/spec_psql.rs` /
12//! `spec_sqlite.rs` / `spec_mysql.rs` fix — those files are the
13//! authoritative specification, and this crate's tests run the generated code
14//! through the same assertions. The factory half is specified the same way,
15//! by `keelson-factory/tests/spec_*.rs`.
16//!
17//! The main entry point is [`run`]; [`generate`] returns the files without
18//! writing them, and [`generate_from_schema`] skips introspection for
19//! callers (and tests) that already hold an IR.
20//!
21//! The generator has a second, independent output: [`queries`] turns
22//! **hand-written `.sql` files** into typed modules (the sqlc-shaped half),
23//! keyed off the `[queries]` section of the same config and reading the same
24//! [`schema::Schema`]. Its docs carry the nullability decision table and the
25//! two-faces design; nothing in the model pipeline depends on it.
26//!
27//! # Where this sits
28//!
29//! Layer 4 of keelson, and the only crate here you run rather than link: it is
30//! a CLI (`cargo install keelson-gen`) that reads a live schema and writes `.rs`
31//! files against [keelson-models](https://docs.rs/keelson-models) and
32//! [keelson-factory](https://docs.rs/keelson-factory), in the dialect of
33//! [keelson-psql](https://docs.rs/keelson-psql), [keelson-mysql](https://docs.rs/keelson-mysql) or
34//! [keelson-sqlite](https://docs.rs/keelson-sqlite). It emits no DDL and tracks no migration
35//! history — point it at the database your migration tool produced. The whole
36//! map is the [keelson](https://docs.rs/keelson) facade crate.
37//!
38//! # The decisions, recorded
39//!
40//! **Introspection: direct catalog queries, not sea-schema.** sea-schema
41//! (SeaORM's introspection crate) was evaluated against querying
42//! `pg_catalog` / `sqlite_master` directly, per dialect:
43//!
44//! - *Weight:* sea-schema brings sea-query plus an async runtime coupling
45//!   (its discovery API is async over sqlx), where this crate otherwise
46//!   needs only the sync `rusqlite` and `postgres` clients **already in the
47//!   workspace** for keelson-sqlcheck's live judges. A dependency that heavy
48//!   must earn its keep, and here it cannot:
49//! - *Lossy middle layer:* sea-schema normalises types into its own
50//!   `ColumnType` enum, which would have to be translated back into type
51//!   *names* to feed `docs/type-mappings.md`'s table and the config's
52//!   `db_type` matchers. Querying `format_type(...)` / the declared SQLite
53//!   type text hands the type map its keys verbatim.
54//! - *Determinism:* owning the catalog queries means owning every `ORDER
55//!   BY`; byte-identical output is a contract, not a hope.
56//!
57//! So: SQLite via `sqlite_master` + `pragma_table_info` /
58//! `pragma_foreign_key_list` (rusqlite), PostgreSQL via `pg_catalog` +
59//! `format_type` (postgres), MySQL via `information_schema` + `COLUMN_TYPE`
60//! (mysql) — the same pattern, and the same reason for taking each type
61//! spelling verbatim (`COLUMN_TYPE`, not `DATA_TYPE`, because only the
62//! former distinguishes `tinyint(1)` from `tinyint`).
63//!
64//! **Schema provenance is the user's migration flow.** keelson-gen takes a
65//! connection string and reads what is there; it neither parses migration
66//! files nor tracks schema history. Point it at the database your
67//! migrations produced.
68//!
69//! **Determinism.** Same schema + same config ⇒ byte-identical files:
70//! tables sorted by name, columns in catalog order, foreign keys sorted by
71//! column list, one bundled formatter (prettyplease — the user's rustfmt
72//! version never touches the output), a fixed header with no timestamps.
73//! Pinned by a generate-twice test.
74//!
75//! **Generated files are never hand-edited (the sqlc stance), and hooks
76//! live outside them.** bob regenerates wholesale and so does keelson-gen:
77//! every emitted file starts with `@generated … DO NOT EDIT`. The spec
78//! models show application-written hooks *inside* the `Table` impl, which a
79//! wholesale regenerator would clobber — the recorded resolution is
80//! **config-declared hook delegation**: `[tables.users] hooks =
81//! ["before_insert", …]` makes the generator emit an override of exactly
82//! those trait methods, each a one-line delegation to
83//! `<hooks.module>::users::before_insert(…)` — a module the application
84//! writes by hand, outside the generated directory. Unlisted hooks stay
85//! trait defaults (nothing is emitted, per the models crate's design), a
86//! listed-but-unwritten hook is a compile error naming the missing path,
87//! and regeneration can never eat application code because application code
88//! never lives in a generated file.
89//!
90//! **Overrides must bind, at one named line.** Every column whose type came
91//! from `[types.map]` or `[[types.override]]` emits
92//! `const _: () = keelson_exec::assert_bind::<T>();` under a doc comment
93//! naming the column — a replacement type that cannot bind fails to compile
94//! on that line, not in an inference swamp (the contract
95//! `keelson_exec::Bind` was built for).
96//!
97//! **Dialects.** PostgreSQL and SQLite are identical in shape (both have
98//! `RETURNING` and `DEFAULT VALUES`); the machinery differences live
99//! entirely in which crate the statements come from. MySQL is deliberately
100//! *not* a copy of that path, because it has no `RETURNING` anywhere: its
101//! `Table` body writes a plain `INSERT` (an all-unset setter being MySQL's
102//! `VALUES ()`), and the model hands out its **marker** from `table()`
103//! rather than `ModelTable`, with inherent verbs that can be honoured —
104//! `insert(…).one()` inserts and then re-`SELECT`s by key (the setter's own
105//! primary key, else `last_insert_id`), `update`/`delete` offer `exec` and
106//! no `all`. The read-back is two statements and says so, in the generated
107//! docs and in `keelson-models/tests/spec_mysql.rs`, which is the
108//! specification this emits.
109//!
110//! **Every to-one relation field is `Option<Box<Row>>`.** A generated `Rel`
111//! holds the target's whole row, so two models that reference each other
112//! to-one hold each other by value — which is a recursive type of infinite
113//! size, a compile error in the emitted code that no user of this generator
114//! could work around. Two base tables with mutual single-column foreign keys
115//! are enough to produce it. Boxing is therefore unconditional rather than
116//! applied only to the edges that close a cycle: a field's type must not be
117//! a function of the whole schema graph, or adding an unrelated foreign key
118//! would change an existing struct and break code at a distance. To-many
119//! fields stay `Vec<Row>`, which carries its own indirection. The argument
120//! is recorded in full in `emit/model.rs`.
121//!
122//! **Factories are opt-in output, not a second generator.** `[output]
123//! factories = true` adds one `factories.rs` — a keelson-factory template
124//! module per writable table, exactly as `keelson-factory/tests/spec_*.rs`
125//! specifies, writing through the *model's* insert path so hooks fire. It is
126//! off by default: a production crate has no reason to carry test-data
127//! machinery it never calls. The per-column default rule (unique columns
128//! take sequences, defaulted columns are omitted, the rest are faked) is
129//! recorded in `emit/factory.rs`.
130//!
131//! **Views are configured, not inferred** (`docs/views.md`). A view has no
132//! foreign keys and usually no primary key, so the catalog cannot say how it
133//! relates to anything or what identifies a row of it. Neither is guessed:
134//! a relation touching a view is a `[[relationships]]` declaration carrying
135//! an explicit `cardinality`, validated against the introspected schema so a
136//! typo is a generation-time error naming the TOML key; and identity is
137//! simply not required for reads, because the loaders group by the declared
138//! join column rather than by a row identity. A keyless view therefore
139//! *holds* and *is the target of* relations while getting less than a table
140//! — no `Pk`, no `Setter`, no `INSERT`/`UPDATE`/`DELETE`, no keyed read-back
141//! on MySQL, no factory. It earns the write surface only by declaring
142//! `[tables.<name>] key`, and only when the engine says writes reach it,
143//! which the three engines decide differently (PostgreSQL's
144//! `pg_relation_is_updatable`, MySQL's `IS_UPDATABLE`, SQLite's `INSTEAD OF`
145//! triggers).
146//!
147//! **Recorded limitations.** Multi-column foreign keys are introspected but
148//! emit no relation (composite keys still work as `Pk` tuples); a base table
149//! whose primary key falls to the column filters demotes to a view model;
150//! `[output] factories = true` cannot cover a writable view and says so.
151
152#![warn(missing_docs)]
153
154pub mod config;
155mod emit;
156mod error;
157pub mod introspect;
158mod names;
159pub mod queries;
160mod resolve;
161pub mod schema;
162mod typemap;
163
164use std::path::{Path, PathBuf};
165
166pub use config::Config;
167pub use error::{GenError, Result};
168pub use typemap::ResolvedType;
169
170/// Introspect the configured database and render every generated file as
171/// `(file name, contents)`, `mod.rs` first — without touching the
172/// filesystem.
173pub fn generate(config: &Config) -> Result<Vec<(String, String)>> {
174    let schema = introspect::introspect(config)?;
175    generate_from_schema(&schema, config)
176}
177
178/// Render from an already-held [`schema::Schema`] — the seam tests and
179/// build scripts use to skip the database.
180pub fn generate_from_schema(
181    schema: &schema::Schema,
182    config: &Config,
183) -> Result<Vec<(String, String)>> {
184    // Refuse unsupported dialects before anything else, so the error names
185    // the real gap rather than the first unmapped column type.
186    emit::Dial::new(config.dialect)?;
187    let mut schema = schema.clone();
188    introspect::canonicalise(&mut schema);
189    let models = resolve::resolve(&schema, config)?;
190    emit::render(&models, config)
191}
192
193/// Write rendered files into `out_dir`, creating it if needed. Returns the
194/// written paths. Stale files from earlier runs are removed only if they
195/// carry the `@generated` header, so a hand-written file dropped into the
196/// directory by mistake is never deleted silently.
197pub fn write_files(out_dir: &Path, files: &[(String, String)]) -> Result<Vec<PathBuf>> {
198    std::fs::create_dir_all(out_dir)?;
199    // Remove generated leftovers whose table has disappeared.
200    for entry in std::fs::read_dir(out_dir)? {
201        let path = entry?.path();
202        let is_ours = path.extension().is_some_and(|e| e == "rs")
203            && std::fs::read_to_string(&path)
204                .is_ok_and(|s| s.starts_with("// @generated by keelson-gen"));
205        let still_wanted = path
206            .file_name()
207            .and_then(|n| n.to_str())
208            .is_some_and(|n| files.iter().any(|(f, _)| f == n));
209        if is_ours && !still_wanted {
210            std::fs::remove_file(&path)?;
211        }
212    }
213    let mut written = Vec::with_capacity(files.len());
214    for (name, contents) in files {
215        let path = out_dir.join(name);
216        std::fs::write(&path, contents)?;
217        written.push(path);
218    }
219    Ok(written)
220}
221
222/// The documented main entry: introspect, render, write to the configured
223/// output directory. This is what the `keelson-gen` binary calls.
224pub fn run(config: &Config) -> Result<Vec<PathBuf>> {
225    let out = config.out.clone().ok_or_else(|| {
226        GenError::Config(
227            "no `out` directory in the config (and none given on the command line)".to_owned(),
228        )
229    })?;
230    let files = generate(config)?;
231    write_files(Path::new(&out), &files)
232}