Skip to main content

keelson_gen/
config.rs

1//! The TOML configuration — bob's gen config inventory, ported and adapted.
2//!
3//! What carried over and how it is spelled here:
4//!
5//! - **only / except** — `only` / `except` table lists, plus per-table
6//!   `only_columns` / `except_columns` under `[tables.<name>]`.
7//! - **shared_schema** — `schema` (PostgreSQL): which namespace to
8//!   introspect; the default is `public`.
9//! - **aliases** — `[aliases.<table>]` `singular` / `plural` rename the row
10//!   struct and back-references; `[aliases.<table>.columns]` renames fields
11//!   and column fns (the SQL name is untouched);
12//!   `[aliases.<table>.relationships]` renames relation fields/mods.
13//! - **inflections** — `[inflections]` maps irregular plurals to their
14//!   singular (`people = "person"`).
15//! - **relationships** — `[[relationships]]` declares a foreign key the
16//!   schema does not (FK-less schemas, views). `cardinality` is optional
17//!   between two base tables and **required** when either end is a view; see
18//!   [`Cardinality`] and `docs/views.md`.
19//! - **no_back_referencing** — global flag, plus `no_back_reference` on a
20//!   manual relationship.
21//! - **key** — not in bob. `[tables.<name>] key = [...]` declares the
22//!   identity of a relation the catalog gives none (a view, a keyless
23//!   table), which is what turns a `SELECT`-only model into a writable one.
24//!   Only accepted when the engine says writes reach the relation.
25//! - **replacements / types** — `[types.map]` re-maps a database type
26//!   everywhere; `[[types.override]]` re-maps columns matched by
27//!   name/db_type/nullable/default/autoincrement/comment, optionally scoped
28//!   to tables. Every override emits an `assert_bind` line in the generated
29//!   file, so a non-binding replacement is a compile error (see the crate
30//!   docs).
31//! - **Go struct-tag options → serde-attribute options** — `[output]
32//!   serde = true` derives `serde::Serialize`/`Deserialize` on row and `Rel`
33//!   structs.
34//! - **hooks** — not in bob (bob's hooks are runtime opt-in); here
35//!   `[tables.<name>] hooks = [...]` opts a table into delegating hook
36//!   overrides, aimed at the module named by `[hooks] module`.
37
38use std::collections::BTreeMap;
39use std::fmt;
40use std::path::Path;
41
42use serde::Deserialize;
43
44use crate::error::{GenError, Result};
45
46/// Which dialect to introspect and emit for.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum Dialect {
50    /// PostgreSQL: introspect `pg_catalog`, emit against `keelson_psql`.
51    Psql,
52    /// SQLite: introspect `sqlite_master`/pragmas, emit against
53    /// `keelson_sqlite`.
54    Sqlite,
55    /// MySQL: introspect `information_schema`, emit against `keelson_mysql`
56    /// — with the no-`RETURNING` mutation surface (see the crate docs).
57    Mysql,
58}
59
60/// The seven hook methods a table can opt into delegating.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum Hook {
64    /// keelson-models' `Table::before_insert`.
65    BeforeInsert,
66    /// keelson-models' `Table::after_insert`.
67    AfterInsert,
68    /// keelson-models' `Table::before_update`.
69    BeforeUpdate,
70    /// keelson-models' `Table::after_update`.
71    AfterUpdate,
72    /// keelson-models' `Table::before_delete`.
73    BeforeDelete,
74    /// keelson-models' `Table::after_delete`.
75    AfterDelete,
76    /// keelson-models' `View::after_select`.
77    AfterSelect,
78}
79
80impl Hook {
81    /// The method (and hand-written hook fn) name.
82    pub fn method(self) -> &'static str {
83        match self {
84            Hook::BeforeInsert => "before_insert",
85            Hook::AfterInsert => "after_insert",
86            Hook::BeforeUpdate => "before_update",
87            Hook::AfterUpdate => "after_update",
88            Hook::BeforeDelete => "before_delete",
89            Hook::AfterDelete => "after_delete",
90            Hook::AfterSelect => "after_select",
91        }
92    }
93}
94
95impl fmt::Display for Hook {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(self.method())
98    }
99}
100
101/// The whole configuration file.
102#[derive(Debug, Clone, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct Config {
105    /// The dialect to introspect and emit for.
106    pub dialect: Dialect,
107    /// The connection string (`sqlite://path` or a plain path;
108    /// `postgres://…`). Schema provenance is the user's migration flow's
109    /// business — the generator reads whatever the connection sees.
110    #[serde(default)]
111    pub url: Option<String>,
112    /// The directory the generated files land in.
113    #[serde(default)]
114    pub out: Option<String>,
115    /// PostgreSQL: the namespace to introspect (bob's shared_schema).
116    #[serde(default = "default_schema")]
117    pub schema: String,
118    /// Emit no has-many back-references anywhere.
119    #[serde(default)]
120    pub no_back_referencing: bool,
121    /// When non-empty, only these tables are generated.
122    #[serde(default)]
123    pub only: Vec<String>,
124    /// These tables are skipped.
125    #[serde(default)]
126    pub except: Vec<String>,
127    /// Output options.
128    #[serde(default)]
129    pub output: Output,
130    /// Where the application's hand-written hook functions live.
131    #[serde(default)]
132    pub hooks: Hooks,
133    /// Irregular plural → singular (`people = "person"`).
134    #[serde(default)]
135    pub inflections: BTreeMap<String, String>,
136    /// Per-table options, keyed by table name.
137    #[serde(default)]
138    pub tables: BTreeMap<String, TableConfig>,
139    /// Renames, keyed by table name.
140    #[serde(default)]
141    pub aliases: BTreeMap<String, TableAliases>,
142    /// Manual relationships for keys the schema does not declare.
143    #[serde(default)]
144    pub relationships: Vec<ManualRelationship>,
145    /// The user-overridable type map.
146    #[serde(default)]
147    pub types: Types,
148    /// Hand-written SQL → typed code (Layer 4). Absent means no query files
149    /// are generated from; see [`crate::queries`].
150    #[serde(default)]
151    pub queries: Option<crate::queries::QueriesConfig>,
152}
153
154fn default_schema() -> String {
155    "public".to_owned()
156}
157
158/// Output options.
159#[derive(Debug, Clone, Default, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct Output {
162    /// Derive `serde::Serialize`/`Deserialize` on row and `Rel` structs (the
163    /// serde-attribute port of bob's Go struct-tag options).
164    #[serde(default)]
165    pub serde: bool,
166    /// Emit `factories.rs` beside the models: one keelson-factory template
167    /// module per writable table, as `keelson-factory/tests/spec_*.rs`
168    /// specifies. Off by default — a production crate has no reason to carry
169    /// test-data machinery it never calls.
170    #[serde(default)]
171    pub factories: bool,
172}
173
174/// Where hand-written hooks live.
175#[derive(Debug, Clone, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct Hooks {
178    /// The module path generated hook overrides delegate to; the application
179    /// writes `<module>::<table>::<hook>` functions there, outside the
180    /// generated tree.
181    #[serde(default = "default_hooks_module")]
182    pub module: String,
183}
184
185impl Default for Hooks {
186    fn default() -> Self {
187        Hooks {
188            module: default_hooks_module(),
189        }
190    }
191}
192
193fn default_hooks_module() -> String {
194    "crate::hooks".to_owned()
195}
196
197/// Per-table options.
198#[derive(Debug, Clone, Default, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct TableConfig {
201    /// When non-empty, only these columns are generated.
202    #[serde(default)]
203    pub only_columns: Vec<String>,
204    /// These columns are skipped.
205    #[serde(default)]
206    pub except_columns: Vec<String>,
207    /// The hook methods this table delegates to the hooks module.
208    #[serde(default)]
209    pub hooks: Vec<Hook>,
210    /// The identity of a relation the catalog gives none: a view, or a base
211    /// table declared without a primary key. Declaring it is what turns the
212    /// `SELECT`-only model into a writable one — and it is accepted **only**
213    /// when the engine says writes reach the relation (see
214    /// [`TableKind::UpdatableView`](crate::schema::TableKind::UpdatableView)).
215    /// Declaring it on a relation that already has a primary key is an
216    /// error: the catalog's answer is not the configuration's to overrule.
217    #[serde(default)]
218    pub key: Vec<String>,
219}
220
221/// Renames for one table.
222#[derive(Debug, Clone, Default, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct TableAliases {
225    /// Row-struct base name (default: singularised table name).
226    #[serde(default)]
227    pub singular: Option<String>,
228    /// Back-reference field name on other models (default: the table name).
229    #[serde(default)]
230    pub plural: Option<String>,
231    /// Column → field/fn rename. The SQL name is untouched.
232    #[serde(default)]
233    pub columns: BTreeMap<String, String>,
234    /// Relation → field/mod rename, keyed by the default relation name.
235    #[serde(default)]
236    pub relationships: BTreeMap<String, String>,
237}
238
239/// How many rows sit on each end of a declared relation.
240///
241/// A foreign key answers this for itself: the referenced side is a key, so
242/// it is the "one", and the referencing side is the "many". A view answers
243/// nothing — it has no key and no constraint — so a relation that touches
244/// one must say which it is. The declaration is an assertion the generator
245/// takes on trust and cannot check; what it buys is the shape of the
246/// back-reference.
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
248#[serde(rename_all = "snake_case")]
249pub enum Cardinality {
250    /// Many referencing rows per referenced row: the referencing side gets a
251    /// to-one relation, the referenced side a `Vec` back-reference. This is
252    /// what a foreign key means, and the default.
253    #[default]
254    ManyToOne,
255    /// One row on each side: the referencing side gets a to-one relation and
256    /// the referenced side an `Option` back-reference rather than a `Vec`.
257    OneToOne,
258}
259
260impl fmt::Display for Cardinality {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        f.write_str(match self {
263            Cardinality::ManyToOne => "many_to_one",
264            Cardinality::OneToOne => "one_to_one",
265        })
266    }
267}
268
269/// A foreign key the schema does not declare (bob's manual relationships,
270/// which double as its manual constraints for FK-less joins) — and the only
271/// way to relate a view to anything, since a view has no foreign keys and
272/// usually no key at all.
273#[derive(Debug, Clone, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct ManualRelationship {
276    /// The referencing (child) table or view.
277    pub table: String,
278    /// The referencing column.
279    pub column: String,
280    /// The referenced (parent) table or view.
281    pub ref_table: String,
282    /// The referenced column.
283    pub ref_column: String,
284    /// The relation name on the child (default: column minus `_id`).
285    #[serde(default)]
286    pub name: Option<String>,
287    /// Emit no has-many back-reference on the parent for this key.
288    #[serde(default)]
289    pub no_back_reference: bool,
290    /// How many rows sit on each end. Optional between two base tables,
291    /// where the referenced column's key constraint answers it; **required**
292    /// when either end is a view, because nothing in the catalog does.
293    #[serde(default)]
294    pub cardinality: Option<Cardinality>,
295}
296
297/// The user-overridable type map.
298#[derive(Debug, Clone, Default, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct Types {
301    /// Database type → Rust type path, applied everywhere after per-column
302    /// overrides. Keys are matched case-insensitively, precision stripped
303    /// (`numeric(10,2)` matches `numeric`).
304    #[serde(default)]
305    pub map: BTreeMap<String, String>,
306    /// Per-column overrides, first match wins.
307    #[serde(default, rename = "override")]
308    pub overrides: Vec<TypeOverride>,
309}
310
311/// One per-column type override.
312#[derive(Debug, Clone, Deserialize)]
313#[serde(deny_unknown_fields)]
314pub struct TypeOverride {
315    /// Table scope; empty means every table.
316    #[serde(default)]
317    pub tables: Vec<String>,
318    /// What the column must look like to match.
319    #[serde(rename = "match", default)]
320    pub matcher: Matcher,
321    /// The Rust type to emit (a path, e.g. `chrono::NaiveDateTime` or
322    /// `crate::types::UserId`). The generated file asserts it binds.
323    pub rust_type: String,
324}
325
326/// The column matcher: every present field must match.
327#[derive(Debug, Clone, Default, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct Matcher {
330    /// Column name, exact.
331    #[serde(default)]
332    pub name: Option<String>,
333    /// Declared database type, case-insensitive, precision stripped.
334    #[serde(default)]
335    pub db_type: Option<String>,
336    /// Nullability.
337    #[serde(default)]
338    pub nullable: Option<bool>,
339    /// Default expression text, exact (`"CURRENT_TIMESTAMP"`).
340    #[serde(default)]
341    pub default: Option<String>,
342    /// Auto-increment.
343    #[serde(default)]
344    pub autoincrement: Option<bool>,
345    /// Column comment, exact (PostgreSQL).
346    #[serde(default)]
347    pub comment: Option<String>,
348}
349
350impl Config {
351    /// Parse a configuration from TOML text.
352    pub fn from_toml(text: &str) -> Result<Config> {
353        toml::from_str(text).map_err(|e| GenError::Config(e.to_string()))
354    }
355
356    /// Read and parse a configuration file.
357    pub fn load(path: impl AsRef<Path>) -> Result<Config> {
358        let text = std::fs::read_to_string(path.as_ref())?;
359        Config::from_toml(&text)
360    }
361
362    /// Whether `table` survives the `only`/`except` filters.
363    pub fn includes_table(&self, table: &str) -> bool {
364        if self.except.iter().any(|t| t == table) {
365            return false;
366        }
367        self.only.is_empty() || self.only.iter().any(|t| t == table)
368    }
369
370    /// Whether `table.column` survives the per-table column filters.
371    pub fn includes_column(&self, table: &str, column: &str) -> bool {
372        let Some(tc) = self.tables.get(table) else {
373            return true;
374        };
375        if tc.except_columns.iter().any(|c| c == column) {
376            return false;
377        }
378        tc.only_columns.is_empty() || tc.only_columns.iter().any(|c| c == column)
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn a_minimal_config_parses_with_defaults() {
388        let c = Config::from_toml("dialect = \"sqlite\"").unwrap();
389        assert_eq!(c.dialect, Dialect::Sqlite);
390        assert_eq!(c.schema, "public");
391        assert_eq!(c.hooks.module, "crate::hooks");
392        assert!(!c.no_back_referencing);
393        assert!(c.includes_table("anything"));
394    }
395
396    #[test]
397    fn the_full_inventory_parses() {
398        let c = Config::from_toml(
399            r#"
400            dialect = "psql"
401            url = "postgres://localhost/app"
402            out = "src/models"
403            schema = "app"
404            no_back_referencing = true
405            only = ["users", "posts"]
406            except = ["schema_migrations"]
407
408            [output]
409            serde = true
410
411            [hooks]
412            module = "crate::model_hooks"
413
414            [inflections]
415            people = "person"
416
417            [tables.users]
418            except_columns = ["password_digest"]
419            hooks = ["before_insert", "after_select"]
420
421            [aliases.users]
422            singular = "member"
423            plural = "membership"
424            [aliases.users.columns]
425            created_at = "created"
426            [aliases.users.relationships]
427            posts = "articles"
428
429            [[relationships]]
430            table = "posts"
431            column = "author_name"
432            ref_table = "users"
433            ref_column = "name"
434            name = "author"
435            no_back_reference = true
436
437            [types.map]
438            citext = "String"
439
440            [[types.override]]
441            tables = ["users"]
442            rust_type = "crate::types::UserId"
443            [types.override.match]
444            name = "id"
445            db_type = "integer"
446            nullable = false
447            "#,
448        )
449        .unwrap();
450        assert_eq!(c.dialect, Dialect::Psql);
451        assert!(c.includes_table("users"));
452        assert!(!c.includes_table("schema_migrations"));
453        assert!(!c.includes_table("tags"), "only wins");
454        assert!(!c.includes_column("users", "password_digest"));
455        assert_eq!(
456            c.tables["users"].hooks,
457            vec![Hook::BeforeInsert, Hook::AfterSelect]
458        );
459        assert_eq!(c.aliases["users"].columns["created_at"], "created");
460        assert_eq!(c.relationships[0].name.as_deref(), Some("author"));
461        assert_eq!(c.types.overrides[0].matcher.name.as_deref(), Some("id"));
462    }
463
464    #[test]
465    fn unknown_keys_are_config_errors_not_silent_noise() {
466        let err = Config::from_toml("dialect = \"sqlite\"\ntypo_key = 1").unwrap_err();
467        assert!(matches!(err, GenError::Config(_)), "{err}");
468    }
469}