Skip to main content

doido_generators/generators/
storage_install.rs

1//! `doido generate storage:install` — the `active_storage:install` analogue.
2//!
3//! Emits a migration creating the `storage_blobs`, `storage_attachments` and
4//! `storage_variant_records` tables (via the `doido_model::migration` builders),
5//! registers it in `db/migration/src/lib.rs`, and appends a `storage:` section to
6//! `config/development.yml` and `config/test.yml` when those files exist.
7
8use crate::generator::{GeneratedFile, Generator};
9use crate::generators::migration_support::{
10    register_migration, render_migration_file, MIGRATION_LIB_BASE, MIGRATION_SRC_DIR,
11};
12use chrono::Utc;
13use doido_core::Result;
14
15pub struct StorageInstallGenerator;
16
17const IMPORTS: &str = "use doido::model::migration::{add_index, create_table, drop_table};";
18
19const UP_BODY: &str = r#"        create_table(manager, "storage_blobs", |t| {
20            t.string("key").not_null().unique_key();
21            t.string("filename").not_null();
22            t.string("content_type");
23            t.text("metadata");
24            t.string("service_name").not_null();
25            t.big_integer("byte_size").not_null();
26            t.string("checksum");
27            t.timestamp("created_at").not_null();
28        })
29        .await?;
30        create_table(manager, "storage_attachments", |t| {
31            t.string("name").not_null();
32            t.string("record_type").not_null();
33            t.string("record_id").not_null();
34            t.string("blob_key").not_null();
35            t.timestamp("created_at").not_null();
36        })
37        .await?;
38        create_table(manager, "storage_variant_records", |t| {
39            t.string("blob_key").not_null();
40            t.string("variation_digest").not_null();
41        })
42        .await?;
43        add_index(
44            manager,
45            "storage_attachments",
46            &["record_type", "record_id", "name"],
47        )
48        .await?;
49        add_index(
50            manager,
51            "storage_variant_records",
52            &["blob_key", "variation_digest"],
53        )
54        .await?;
55        Ok(())
56"#;
57
58const DOWN_BODY: &str = r#"        drop_table(manager, "storage_variant_records").await?;
59        drop_table(manager, "storage_attachments").await?;
60        drop_table(manager, "storage_blobs").await
61"#;
62
63/// The `storage:` config block appended to a `config/<env>.yml`. `active` is the
64/// service selected for that environment.
65fn storage_section(active: &str) -> String {
66    format!(
67        "\nstorage:\n  service: {active}\n  services:\n    local:\n      type: disk\n      root: storage\n    test:\n      type: memory\n"
68    )
69}
70
71/// Append the storage section to `path` if the file exists and has none yet.
72fn config_file(path: &str, active: &str) -> Option<GeneratedFile> {
73    let existing = std::fs::read_to_string(path).ok()?;
74    if existing.contains("storage:") {
75        return None;
76    }
77    Some(GeneratedFile {
78        path: path.to_string(),
79        content: format!("{}{}", existing.trim_end(), storage_section(active)),
80    })
81}
82
83impl Generator for StorageInstallGenerator {
84    fn name(&self) -> &str {
85        "storage:install"
86    }
87
88    fn generate(&self, _args: &[&str]) -> Result<Vec<GeneratedFile>> {
89        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
90        let migration_module = format!("m{timestamp}_create_storage_tables");
91        let migration = render_migration_file(&migration_module, IMPORTS, UP_BODY, DOWN_BODY);
92
93        let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
94        let existing =
95            std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
96        let lib = register_migration(&existing, &migration_module);
97
98        let mut files = vec![
99            GeneratedFile {
100                path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
101                content: migration,
102            },
103            GeneratedFile {
104                path: lib_path,
105                content: lib,
106            },
107        ];
108
109        // Best-effort: wire the `storage` config section into existing env files.
110        if let Some(f) = config_file("config/development.yml", "local") {
111            files.push(f);
112        }
113        if let Some(f) = config_file("config/test.yml", "test") {
114            files.push(f);
115        }
116
117        Ok(files)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn emits_migration_and_registers_it() {
127        let files = StorageInstallGenerator.generate(&[]).unwrap();
128        // A migration file plus the updated lib.rs (config files depend on cwd).
129        let migration = files
130            .iter()
131            .find(|f| f.path.contains("create_storage_tables") && f.path.ends_with(".rs"))
132            .expect("migration file emitted");
133        assert!(migration.content.contains("storage_blobs"));
134        assert!(migration.content.contains("storage_attachments"));
135        assert!(migration.content.contains("storage_variant_records"));
136        assert!(migration
137            .content
138            .contains("impl MigrationName for Migration"));
139        assert!(!migration.content.contains("DeriveMigrationName"));
140        let module = migration
141            .path
142            .strip_prefix("db/migration/src/")
143            .unwrap()
144            .strip_suffix(".rs")
145            .unwrap();
146        assert!(migration.content.contains(&format!("\"{module}\"")));
147
148        let lib = files
149            .iter()
150            .find(|f| f.path.ends_with("lib.rs"))
151            .expect("lib.rs emitted");
152        assert!(lib.content.contains("create_storage_tables"));
153    }
154}