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//! A user-supplied `-d/--migration-dir` or `-o/--output-dir` always wins.
12
13use clap::Subcommand;
14use doido_model::sea_orm_cli::{
15    handle_error, run_generate_command, run_migrate_command, Commands, GenerateSubcommands,
16};
17
18/// Subcommands of `doido db`: Doido's `create` plus the flattened SeaORM CLI.
19#[derive(Subcommand)]
20// The flattened SeaORM `Commands` is large, but this is parsed once at startup
21// and can't be boxed through clap's `#[command(flatten)]`.
22#[allow(clippy::large_enum_variant)]
23pub enum DbCommand {
24    /// Create the database for the current environment
25    Create,
26    /// Drop every table and reload `db/schema.sql`
27    Reset,
28    /// Load `db/schema.sql` only if the database has no tables yet (idempotent)
29    Prepare,
30    /// Run the `db/seed` crate (Rust models-based seeder)
31    Seed,
32    /// Schema dump/load (`db/schema.sql`)
33    Schema {
34        #[command(subcommand)]
35        action: SchemaCommand,
36    },
37    /// SeaORM CLI commands (migrate, generate entity)
38    #[command(flatten)]
39    SeaOrm(Commands),
40}
41
42/// Subcommands of `doido db schema`.
43#[derive(Subcommand)]
44pub enum SchemaCommand {
45    /// Dump the current schema to `db/schema.sql`
46    Dump,
47    /// Load `db/schema.sql` into the database
48    Load,
49}
50
51/// Where Doido keeps its SeaORM migration crate.
52const DEFAULT_MIGRATION_DIR: &str = "db/migration";
53/// Where Doido keeps its Rust seed runner crate.
54const DEFAULT_SEED_DIR: &str = "db/seed";
55/// Where Doido writes generated SeaORM entities.
56const DEFAULT_ENTITY_OUTPUT_DIR: &str = "app/models/_entities";
57/// Canonical schema file (Rails `db/schema.rb` analogue).
58const SCHEMA_FILE: &str = "db/schema.sql";
59/// Upstream SeaORM CLI defaults — used to detect "the user didn't override this".
60const SEA_ORM_CLI_DEFAULT_MIGRATION_DIR: &str = "./migration";
61const SEA_ORM_CLI_DEFAULT_OUTPUT_DIR: &str = "./";
62
63/// Populates `DATABASE_URL` from the app's `config/<env>.yml` (`database.url`)
64/// when it isn't already set in the environment.
65///
66/// SeaORM CLI reads the database URL from the `DATABASE_URL` env var (both
67/// `migrate` and `generate entity` bind to it). Seeding it from config means
68/// `doido db …` works without the user exporting `DATABASE_URL` by hand, while
69/// an explicit `-u/--database-url` or a pre-set env var still wins. Call this
70/// before clap parses so the required `generate entity` URL is satisfied.
71pub fn ensure_database_url_from_config() {
72    if std::env::var_os("DATABASE_URL").is_some() {
73        return;
74    }
75    // Only seed from a real config file; absent config leaves DATABASE_URL unset
76    // so the user gets the usual "missing database URL" error rather than a
77    // surprising default.
78    if let Ok(config) = doido_model::config::YamlConfig::load() {
79        std::env::set_var("DATABASE_URL", config.database.url);
80    }
81}
82
83/// Runs a `doido db <command>`.
84pub async fn run(command: DbCommand, verbose: bool) {
85    match command {
86        DbCommand::Create => create().await,
87        DbCommand::Reset => reset().await,
88        DbCommand::Prepare => prepare().await,
89        DbCommand::Seed => seed().await,
90        DbCommand::Schema { action } => schema(action).await,
91        DbCommand::SeaOrm(command) => run_sea_orm(command, verbose).await,
92    }
93}
94
95/// Opens a connection to the resolved [`database_url`], exiting on failure.
96async fn connect() -> doido_model::DatabaseConnection {
97    let url = database_url();
98    match doido_model::connect_with_url(&url).await {
99        Ok(conn) => conn,
100        Err(e) => {
101            doido_core::tracing::error!("failed to connect to {url}: {e}");
102            std::process::exit(1);
103        }
104    }
105}
106
107/// Reads a file, logging (and returning `None`) on failure.
108fn read_sql_file(path: &str) -> Option<String> {
109    match std::fs::read_to_string(path) {
110        Ok(contents) => Some(contents),
111        Err(e) => {
112            doido_core::tracing::error!("could not read {path}: {e}");
113            None
114        }
115    }
116}
117
118/// `doido db reset` — drop everything, then reload `db/schema.sql`.
119async fn reset() {
120    let Some(schema) = read_sql_file(SCHEMA_FILE) else {
121        return;
122    };
123    let conn = connect().await;
124    match doido_model::tasks::reset(&conn, &schema).await {
125        Ok(()) => doido_core::tracing::info!("reset database from {SCHEMA_FILE}"),
126        Err(e) => doido_core::tracing::error!("db reset failed: {e}"),
127    }
128}
129
130/// `doido db prepare` — load `db/schema.sql` only if the database is empty.
131async fn prepare() {
132    let Some(schema) = read_sql_file(SCHEMA_FILE) else {
133        return;
134    };
135    let conn = connect().await;
136    match doido_model::tasks::prepare(&conn, &schema).await {
137        Ok(()) => doido_core::tracing::info!("prepared database from {SCHEMA_FILE}"),
138        Err(e) => doido_core::tracing::error!("db prepare failed: {e}"),
139    }
140}
141
142/// Program + args to run the seed crate (`db/seed`).
143pub fn seed_command() -> (String, Vec<String>) {
144    (
145        "cargo".to_string(),
146        vec![
147            "run".to_string(),
148            "--quiet".to_string(),
149            "--manifest-path".to_string(),
150            format!("{DEFAULT_SEED_DIR}/Cargo.toml"),
151        ],
152    )
153}
154
155/// `doido db seed` — compile and run the `db/seed` crate, which inserts data
156/// using the app's SeaORM models in `app/models/`.
157async fn seed() {
158    let (program, args) = seed_command();
159    match std::process::Command::new(&program).args(&args).status() {
160        Ok(status) if status.success() => {
161            doido_core::tracing::info!("seeded database via {DEFAULT_SEED_DIR}");
162        }
163        Ok(status) => {
164            doido_core::tracing::error!(
165                "db seed failed: cargo exited with {}",
166                status.code().unwrap_or(-1)
167            );
168        }
169        Err(e) => doido_core::tracing::error!("db seed failed: {e}"),
170    }
171}
172
173/// `doido db schema dump|load` over [`SCHEMA_FILE`].
174async fn schema(action: SchemaCommand) {
175    let conn = connect().await;
176    match action {
177        SchemaCommand::Dump => match doido_model::schema::dump(&conn).await {
178            Ok(sql) => {
179                if let Some(parent) = std::path::Path::new(SCHEMA_FILE).parent() {
180                    let _ = std::fs::create_dir_all(parent);
181                }
182                match std::fs::write(SCHEMA_FILE, sql) {
183                    Ok(()) => doido_core::tracing::info!("wrote schema to {SCHEMA_FILE}"),
184                    Err(e) => doido_core::tracing::error!("could not write {SCHEMA_FILE}: {e}"),
185                }
186            }
187            Err(e) => doido_core::tracing::error!("schema dump failed: {e}"),
188        },
189        SchemaCommand::Load => {
190            let Some(sql) = read_sql_file(SCHEMA_FILE) else {
191                return;
192            };
193            match doido_model::schema::load(&conn, &sql).await {
194                Ok(()) => doido_core::tracing::info!("loaded schema from {SCHEMA_FILE}"),
195                Err(e) => doido_core::tracing::error!("schema load failed: {e}"),
196            }
197        }
198    }
199}
200
201/// Creates the database named by the resolved [`database_url`].
202async fn create() {
203    let url = database_url();
204    match doido_model::create_database(&url).await {
205        Ok(()) => doido_core::tracing::info!("created database: {url}"),
206        Err(e) if e.to_string().contains("already exists") => {
207            doido_core::tracing::info!("database already exists: {url}");
208        }
209        Err(e) => handle_error(e),
210    }
211}
212
213/// Resolves the database URL from `DATABASE_URL` or `config/<env>.yml`, exiting
214/// with an error if neither is available.
215fn database_url() -> String {
216    if let Ok(url) = std::env::var("DATABASE_URL") {
217        return url;
218    }
219    if let Ok(config) = doido_model::config::YamlConfig::load() {
220        return config.database.url;
221    }
222    doido_core::tracing::error!("DATABASE_URL is not set and config/<env>.yml could not be read");
223    std::process::exit(1);
224}
225
226/// Dispatches a flattened SeaORM CLI command, applying Doido's directory defaults.
227async fn run_sea_orm(command: Commands, verbose: bool) {
228    match command {
229        Commands::Generate { mut command } => {
230            apply_entity_output_default(&mut command);
231            run_generate_command(command, verbose)
232                .await
233                .unwrap_or_else(handle_error);
234        }
235        Commands::Migrate {
236            migration_dir,
237            database_schema,
238            database_url,
239            command,
240        } => {
241            let migration_dir = override_migration_dir(migration_dir);
242            run_migrate_command(
243                command,
244                &migration_dir,
245                database_schema,
246                database_url,
247                verbose,
248            )
249            .unwrap_or_else(handle_error);
250        }
251    }
252}
253
254/// Substitutes Doido's migration directory when the user left the SeaORM default.
255fn override_migration_dir(migration_dir: String) -> String {
256    if migration_dir == SEA_ORM_CLI_DEFAULT_MIGRATION_DIR {
257        DEFAULT_MIGRATION_DIR.to_string()
258    } else {
259        migration_dir
260    }
261}
262
263/// Substitutes Doido's entity output directory when the user left the SeaORM default.
264fn apply_entity_output_default(command: &mut GenerateSubcommands) {
265    let GenerateSubcommands::Entity { output_dir, .. } = command;
266    if output_dir == SEA_ORM_CLI_DEFAULT_OUTPUT_DIR {
267        *output_dir = DEFAULT_ENTITY_OUTPUT_DIR.to_string();
268    }
269}