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}