Skip to main content

doido_generators/commands/
db.rs

1//! `doido db` — database management.
2//!
3//! Exposes every SeaORM CLI subcommand and option verbatim (`doido db migrate
4//! …`, `doido db generate entity …`) plus Doido's own `doido db create`, which
5//! SeaORM does not provide. Doido changes two SeaORM defaults to match its app
6//! layout:
7//!   * migrations live in [`DEFAULT_MIGRATION_DIR`] (`db/migration`), and
8//!   * generated entities are written to [`DEFAULT_ENTITY_OUTPUT_DIR`]
9//!     (`app/models/_entities`).
10//!
11//! After every schema-changing migrate (`up`, `down`, `fresh`, `refresh`, `reset`),
12//! Doido re-exports entities from the database into `_entities/` and ensures
13//! extension stubs exist under `app/models/<name>.rs`.
14//!
15//! A user-supplied `-d/--migration-dir` or `-o/--output-dir` always wins.
16
17use clap::Subcommand;
18use doido_model::sea_orm_cli::{
19    handle_error, run_generate_command, run_migrate_command, BannerVersion, BigIntegerType,
20    Commands, DateTimeCrate, GenerateSubcommands, MigrateSubcommands,
21};
22use std::path::Path;
23
24/// Subcommands of `doido db`: Doido's `create` plus the flattened SeaORM CLI.
25#[derive(Subcommand)]
26// The flattened SeaORM `Commands` is large, but this is parsed once at startup
27// and can't be boxed through clap's `#[command(flatten)]`.
28#[allow(clippy::large_enum_variant)]
29pub enum DbCommand {
30    /// Create the database for the current environment
31    Create,
32    /// Drop every table and reload `db/schema.sql`
33    Reset,
34    /// Load `db/schema.sql` only if the database has no tables yet (idempotent)
35    Prepare,
36    /// Run the `db/seed` crate (Rust models-based seeder)
37    Seed,
38    /// Schema dump/load (`db/schema.sql`)
39    Schema {
40        #[command(subcommand)]
41        action: SchemaCommand,
42    },
43    /// SeaORM CLI commands (migrate, generate entity)
44    #[command(flatten)]
45    SeaOrm(Commands),
46}
47
48/// Subcommands of `doido db schema`.
49#[derive(Subcommand)]
50pub enum SchemaCommand {
51    /// Dump the current schema to `db/schema.sql`
52    Dump,
53    /// Load `db/schema.sql` into the database
54    Load,
55}
56
57/// Where Doido keeps its SeaORM migration crate.
58const DEFAULT_MIGRATION_DIR: &str = "db/migration";
59/// Where Doido keeps its Rust seed runner crate.
60const DEFAULT_SEED_DIR: &str = "db/seed";
61/// Where Doido writes generated SeaORM entities.
62const DEFAULT_ENTITY_OUTPUT_DIR: &str = "app/models/_entities";
63/// Canonical schema file (Rails `db/schema.rb` analogue).
64const SCHEMA_FILE: &str = "db/schema.sql";
65/// Upstream SeaORM CLI defaults — used to detect "the user didn't override this".
66const SEA_ORM_CLI_DEFAULT_MIGRATION_DIR: &str = "./migration";
67const SEA_ORM_CLI_DEFAULT_OUTPUT_DIR: &str = "./";
68
69/// Populates `DATABASE_URL` from the app's `config/<env>.yml` (`database.url`)
70/// when it isn't already set in the environment.
71///
72/// SeaORM CLI reads the database URL from the `DATABASE_URL` env var (both
73/// `migrate` and `generate entity` bind to it). Seeding it from config means
74/// `doido db …` works without the user exporting `DATABASE_URL` by hand, while
75/// an explicit `-u/--database-url` or a pre-set env var still wins. Call this
76/// before clap parses so the required `generate entity` URL is satisfied.
77pub fn ensure_database_url_from_config() {
78    if std::env::var_os("DATABASE_URL").is_some() {
79        return;
80    }
81    // Only seed from a real config file; absent config leaves DATABASE_URL unset
82    // so the user gets the usual "missing database URL" error rather than a
83    // surprising default.
84    if let Ok(config) = doido_model::config::YamlConfig::load() {
85        std::env::set_var("DATABASE_URL", config.database.url);
86    }
87}
88
89/// Runs a `doido db <command>`.
90pub async fn run(command: DbCommand, verbose: bool) {
91    match command {
92        DbCommand::Create => create().await,
93        DbCommand::Reset => reset().await,
94        DbCommand::Prepare => prepare().await,
95        DbCommand::Seed => seed().await,
96        DbCommand::Schema { action } => schema(action).await,
97        DbCommand::SeaOrm(command) => run_sea_orm(command, verbose).await,
98    }
99}
100
101/// Opens a connection to the resolved [`database_url`], exiting on failure.
102async fn connect() -> doido_model::DatabaseConnection {
103    let url = database_url();
104    match doido_model::connect_with_url(&url).await {
105        Ok(conn) => conn,
106        Err(e) => {
107            doido_core::tracing::error!("failed to connect to {url}: {e}");
108            std::process::exit(1);
109        }
110    }
111}
112
113/// Reads a file, logging (and returning `None`) on failure.
114fn read_sql_file(path: &str) -> Option<String> {
115    match std::fs::read_to_string(path) {
116        Ok(contents) => Some(contents),
117        Err(e) => {
118            doido_core::tracing::error!("could not read {path}: {e}");
119            None
120        }
121    }
122}
123
124/// `doido db reset` — drop everything, then reload `db/schema.sql`.
125async fn reset() {
126    let Some(schema) = read_sql_file(SCHEMA_FILE) else {
127        return;
128    };
129    let conn = connect().await;
130    match doido_model::tasks::reset(&conn, &schema).await {
131        Ok(()) => doido_core::tracing::info!("reset database from {SCHEMA_FILE}"),
132        Err(e) => doido_core::tracing::error!("db reset failed: {e}"),
133    }
134}
135
136/// `doido db prepare` — load `db/schema.sql` only if the database is empty.
137async fn prepare() {
138    let Some(schema) = read_sql_file(SCHEMA_FILE) else {
139        return;
140    };
141    let conn = connect().await;
142    match doido_model::tasks::prepare(&conn, &schema).await {
143        Ok(()) => doido_core::tracing::info!("prepared database from {SCHEMA_FILE}"),
144        Err(e) => doido_core::tracing::error!("db prepare failed: {e}"),
145    }
146}
147
148/// Program + args to run the seed crate (`db/seed`).
149pub fn seed_command() -> (String, Vec<String>) {
150    (
151        "cargo".to_string(),
152        vec![
153            "run".to_string(),
154            "--quiet".to_string(),
155            "--manifest-path".to_string(),
156            format!("{DEFAULT_SEED_DIR}/Cargo.toml"),
157        ],
158    )
159}
160
161/// `doido db seed` — compile and run the `db/seed` crate, which inserts data
162/// using the app's SeaORM models in `app/models/`.
163async fn seed() {
164    let (program, args) = seed_command();
165    match std::process::Command::new(&program).args(&args).status() {
166        Ok(status) if status.success() => {
167            doido_core::tracing::info!("seeded database via {DEFAULT_SEED_DIR}");
168        }
169        Ok(status) => {
170            doido_core::tracing::error!(
171                "db seed failed: cargo exited with {}",
172                status.code().unwrap_or(-1)
173            );
174        }
175        Err(e) => doido_core::tracing::error!("db seed failed: {e}"),
176    }
177}
178
179/// `doido db schema dump|load` over [`SCHEMA_FILE`].
180async fn schema(action: SchemaCommand) {
181    let conn = connect().await;
182    match action {
183        SchemaCommand::Dump => match doido_model::schema::dump(&conn).await {
184            Ok(sql) => {
185                if let Some(parent) = std::path::Path::new(SCHEMA_FILE).parent() {
186                    let _ = std::fs::create_dir_all(parent);
187                }
188                match std::fs::write(SCHEMA_FILE, sql) {
189                    Ok(()) => doido_core::tracing::info!("wrote schema to {SCHEMA_FILE}"),
190                    Err(e) => doido_core::tracing::error!("could not write {SCHEMA_FILE}: {e}"),
191                }
192            }
193            Err(e) => doido_core::tracing::error!("schema dump failed: {e}"),
194        },
195        SchemaCommand::Load => {
196            let Some(sql) = read_sql_file(SCHEMA_FILE) else {
197                return;
198            };
199            match doido_model::schema::load(&conn, &sql).await {
200                Ok(()) => doido_core::tracing::info!("loaded schema from {SCHEMA_FILE}"),
201                Err(e) => doido_core::tracing::error!("schema load failed: {e}"),
202            }
203        }
204    }
205}
206
207/// Creates the database named by the resolved [`database_url`].
208async fn create() {
209    let url = database_url();
210    match doido_model::create_database(&url).await {
211        Ok(()) => doido_core::tracing::info!("created database: {url}"),
212        Err(e) if e.to_string().contains("already exists") => {
213            doido_core::tracing::info!("database already exists: {url}");
214        }
215        Err(e) => handle_error(e),
216    }
217}
218
219/// Resolves the database URL from `DATABASE_URL` or `config/<env>.yml`, exiting
220/// with an error if neither is available.
221fn database_url() -> String {
222    if let Ok(url) = std::env::var("DATABASE_URL") {
223        return url;
224    }
225    if let Ok(config) = doido_model::config::YamlConfig::load() {
226        return config.database.url;
227    }
228    doido_core::tracing::error!("DATABASE_URL is not set and config/<env>.yml could not be read");
229    std::process::exit(1);
230}
231
232/// Dispatches a flattened SeaORM CLI command, applying Doido's directory defaults.
233async fn run_sea_orm(command: Commands, verbose: bool) {
234    match command {
235        Commands::Generate { mut command } => {
236            apply_entity_output_default(&mut command);
237            let is_entity = matches!(&command, GenerateSubcommands::Entity { .. });
238            run_generate_command(command, verbose)
239                .await
240                .unwrap_or_else(handle_error);
241            if is_entity {
242                sync_model_extensions();
243            }
244        }
245        Commands::Migrate {
246            migration_dir,
247            database_schema,
248            database_url,
249            command,
250        } => {
251            let migration_dir = override_migration_dir(migration_dir);
252            let export = should_export_entities(command.as_ref());
253            run_migrate_command(
254                command,
255                &migration_dir,
256                database_schema,
257                database_url,
258                verbose,
259            )
260            .unwrap_or_else(handle_error);
261            if export {
262                export_entities_from_database(verbose).await;
263            }
264        }
265    }
266}
267
268/// Whether a migrate subcommand changes the schema enough to warrant re-export.
269fn should_export_entities(command: Option<&MigrateSubcommands>) -> bool {
270    matches!(
271        command,
272        None | Some(MigrateSubcommands::Up { .. })
273            | Some(MigrateSubcommands::Down { .. })
274            | Some(MigrateSubcommands::Fresh)
275            | Some(MigrateSubcommands::Refresh)
276            | Some(MigrateSubcommands::Reset)
277    )
278}
279
280/// Re-export entities from the live database into [`DEFAULT_ENTITY_OUTPUT_DIR`].
281async fn export_entities_from_database(verbose: bool) {
282    ensure_database_url_from_config();
283    let mut command = default_entity_generate_command(database_url());
284    apply_entity_output_default(&mut command);
285    if let Err(e) = run_generate_command(command, verbose).await {
286        handle_error(e);
287    }
288    sync_model_extensions();
289}
290
291fn sync_model_extensions() {
292    let entities_dir = Path::new(DEFAULT_ENTITY_OUTPUT_DIR);
293    let models_dir = Path::new("app/models");
294    match doido_model::entities::postprocess_entity_export(entities_dir, models_dir) {
295        Ok(()) => doido_core::tracing::info!("post-processed exported entities"),
296        Err(e) => doido_core::tracing::error!("entity post-process failed: {e}"),
297    }
298}
299
300fn default_entity_generate_command(database_url: String) -> GenerateSubcommands {
301    GenerateSubcommands::Entity {
302        entity_format: None,
303        compact_format: false,
304        expanded_format: false,
305        frontend_format: false,
306        include_hidden_tables: false,
307        tables: Vec::new(),
308        ignore_tables: vec!["seaql_migrations".to_string()],
309        max_connections: 1,
310        acquire_timeout: 30,
311        output_dir: SEA_ORM_CLI_DEFAULT_OUTPUT_DIR.to_string(),
312        database_schema: None,
313        database_url,
314        with_prelude: "all".to_string(),
315        with_serde: "both".to_string(),
316        serde_skip_deserializing_primary_key: false,
317        serde_skip_hidden_column: false,
318        with_copy_enums: false,
319        date_time_crate: DateTimeCrate::Chrono,
320        big_integer_type: BigIntegerType::I64,
321        lib: false,
322        model_extra_derives: Vec::new(),
323        model_extra_attributes: Vec::new(),
324        enum_extra_derives: Vec::new(),
325        enum_extra_attributes: Vec::new(),
326        column_extra_derives: Vec::new(),
327        seaography: false,
328        impl_active_model_behavior: true,
329        preserve_user_modifications: false,
330        banner_version: BannerVersion::Minor,
331        er_diagram: false,
332    }
333}
334
335/// Substitutes Doido's migration directory when the user left the SeaORM default.
336fn override_migration_dir(migration_dir: String) -> String {
337    if migration_dir == SEA_ORM_CLI_DEFAULT_MIGRATION_DIR {
338        DEFAULT_MIGRATION_DIR.to_string()
339    } else {
340        migration_dir
341    }
342}
343
344/// Substitutes Doido's entity output directory when the user left the SeaORM default.
345fn apply_entity_output_default(command: &mut GenerateSubcommands) {
346    let GenerateSubcommands::Entity { output_dir, .. } = command;
347    if output_dir == SEA_ORM_CLI_DEFAULT_OUTPUT_DIR {
348        *output_dir = DEFAULT_ENTITY_OUTPUT_DIR.to_string();
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn schema_changing_migrate_commands_export_entities() {
358        assert!(should_export_entities(None));
359        assert!(should_export_entities(Some(&MigrateSubcommands::Up {
360            num: None
361        })));
362        assert!(should_export_entities(Some(&MigrateSubcommands::Down {
363            num: 1
364        })));
365        assert!(should_export_entities(Some(&MigrateSubcommands::Fresh)));
366        assert!(!should_export_entities(Some(&MigrateSubcommands::Status)));
367        assert!(!should_export_entities(Some(&MigrateSubcommands::Init)));
368    }
369
370    #[test]
371    fn apply_entity_output_default_rewrites_sea_orm_default() {
372        let mut command = default_entity_generate_command("sqlite://x".into());
373        apply_entity_output_default(&mut command);
374        let GenerateSubcommands::Entity { output_dir, .. } = command;
375        assert_eq!(output_dir, DEFAULT_ENTITY_OUTPUT_DIR);
376    }
377}