Skip to main content

rdbi_codegen/
lib.rs

1//! rdbi-codegen: Generate Rust structs and rdbi DAO functions from MySQL schema DDL
2//!
3//! This crate provides both a CLI tool and a library for generating Rust code
4//! from MySQL schema files. It parses SQL DDL using `sqlparser-rs` and generates:
5//!
6//! - Serde-compatible structs with `#[derive(Serialize, Deserialize, rdbi::FromRow, rdbi::ToParams)]`
7//! - Async DAO functions using rdbi with index-aware query methods
8//!
9//! # Usage in build.rs (Recommended)
10//!
11//! Configure in your `Cargo.toml`:
12//!
13//! ```toml
14//! [package.metadata.rdbi-codegen]
15//! schema_file = "schema.sql"
16//! output_structs_dir = "src/generated/models"
17//! output_dao_dir = "src/generated/dao"
18//! ```
19//!
20//! Then use a minimal `build.rs`:
21//!
22//! ```rust,ignore
23//! fn main() {
24//!     rdbi_codegen::generate_from_cargo_metadata()
25//!         .expect("Failed to generate rdbi code");
26//! }
27//! ```
28//!
29//! Include the generated code in your crate root (`src/main.rs` or `src/lib.rs`):
30//!
31//! ```rust,ignore
32//! mod generated {
33//!     pub mod models;
34//!     pub mod dao;
35//! }
36//! ```
37//!
38//! # Alternative: Programmatic Configuration
39//!
40//! ```rust,ignore
41//! use std::path::PathBuf;
42//!
43//! fn main() {
44//!     rdbi_codegen::CodegenBuilder::new("schema.sql")
45//!         .output_dir(PathBuf::from("src/generated"))
46//!         .generate()
47//!         .expect("Failed to generate rdbi code");
48//!
49//!     println!("cargo:rerun-if-changed=schema.sql");
50//! }
51//! ```
52//!
53//! # CLI Usage
54//!
55//! ```bash
56//! rdbi-codegen --schema schema.sql --output ./src/generated generate
57//! ```
58
59pub mod codegen;
60pub mod config;
61pub mod error;
62pub mod parser;
63
64use std::collections::HashSet;
65use std::path::{Path, PathBuf};
66
67use tracing::{debug, info};
68
69pub use config::CodegenConfig;
70pub use error::{CodegenError, Result};
71
72/// Main entry point for code generation
73pub fn generate(config: &CodegenConfig) -> Result<()> {
74    info!("Parsing schema: {:?}", config.schema_file);
75    let schema_sql = std::fs::read_to_string(&config.schema_file)?;
76    let tables = parser::parse_schema(&schema_sql)?;
77    info!("Found {} tables", tables.len());
78
79    let tables = filter_tables(tables, &config.include_tables, &config.exclude_tables);
80    debug!(
81        "After filtering: {} tables (include={}, exclude={})",
82        tables.len(),
83        config.include_tables,
84        config.exclude_tables
85    );
86
87    if config.generate_structs {
88        info!("Generating structs in {:?}", config.output_structs_dir);
89        codegen::generate_structs(&tables, config)?;
90    }
91    if config.generate_dao {
92        info!("Generating DAOs in {:?}", config.output_dao_dir);
93        codegen::generate_daos(&tables, config)?;
94    }
95
96    // Generate parent mod.rs when both output dirs share a common parent
97    if config.generate_structs && config.generate_dao {
98        if let (Some(structs_parent), Some(dao_parent)) = (
99            config.output_structs_dir.parent(),
100            config.output_dao_dir.parent(),
101        ) {
102            if structs_parent == dao_parent {
103                let structs_dir_name = config
104                    .output_structs_dir
105                    .file_name()
106                    .and_then(|n| n.to_str())
107                    .unwrap_or("models");
108                let dao_dir_name = config
109                    .output_dao_dir
110                    .file_name()
111                    .and_then(|n| n.to_str())
112                    .unwrap_or("dao");
113
114                let parent_mod_path = structs_parent.join("mod.rs");
115                let parent_mod_content = format!(
116                    "// Generated by rdbi-codegen - do not edit manually\n#![allow(dead_code)]\n\npub mod {};\npub mod {};\n",
117                    dao_dir_name, structs_dir_name
118                );
119
120                if !config.dry_run {
121                    std::fs::write(&parent_mod_path, parent_mod_content)?;
122                    info!("Generated parent mod.rs at {:?}", parent_mod_path);
123                }
124            }
125        }
126    }
127
128    info!("Code generation complete");
129    Ok(())
130}
131
132/// Filter tables based on include/exclude patterns
133fn filter_tables(
134    tables: Vec<parser::TableMetadata>,
135    include: &str,
136    exclude: &str,
137) -> Vec<parser::TableMetadata> {
138    let include_all = include.trim() == "*" || include.trim().is_empty();
139    let include_set: HashSet<String> = if include_all {
140        HashSet::new()
141    } else {
142        include.split(',').map(|s| s.trim().to_string()).collect()
143    };
144    let exclude_set: HashSet<String> = exclude
145        .split(',')
146        .map(|s| s.trim().to_string())
147        .filter(|s| !s.is_empty())
148        .collect();
149
150    tables
151        .into_iter()
152        .filter(|t| {
153            let name = &t.name;
154            let included = include_all || include_set.contains(name);
155            let excluded = exclude_set.contains(name);
156            included && !excluded
157        })
158        .collect()
159}
160
161/// Builder pattern for easy configuration in build.rs
162pub struct CodegenBuilder {
163    config: CodegenConfig,
164}
165
166impl CodegenBuilder {
167    /// Create a new builder with the given schema file
168    pub fn new(schema_file: impl AsRef<Path>) -> Self {
169        Self {
170            config: CodegenConfig::default_with_schema(schema_file.as_ref().to_path_buf()),
171        }
172    }
173
174    /// Set the output directory for both structs and DAOs
175    pub fn output_dir(mut self, dir: impl AsRef<Path>) -> Self {
176        let dir = dir.as_ref();
177        self.config.output_structs_dir = dir.join("models");
178        self.config.output_dao_dir = dir.join("dao");
179        self
180    }
181
182    /// Set the output directory for structs only
183    pub fn output_structs_dir(mut self, dir: impl AsRef<Path>) -> Self {
184        self.config.output_structs_dir = dir.as_ref().to_path_buf();
185        self
186    }
187
188    /// Set the output directory for DAOs only
189    pub fn output_dao_dir(mut self, dir: impl AsRef<Path>) -> Self {
190        self.config.output_dao_dir = dir.as_ref().to_path_buf();
191        self
192    }
193
194    /// Set tables to include (comma-separated or array)
195    pub fn include_tables(mut self, tables: &[&str]) -> Self {
196        self.config.include_tables = tables.join(",");
197        self
198    }
199
200    /// Set tables to exclude (comma-separated or array)
201    pub fn exclude_tables(mut self, tables: &[&str]) -> Self {
202        self.config.exclude_tables = tables.join(",");
203        self
204    }
205
206    /// Generate only structs, no DAOs
207    pub fn structs_only(mut self) -> Self {
208        self.config.generate_dao = false;
209        self
210    }
211
212    /// Generate only DAOs, no structs
213    pub fn dao_only(mut self) -> Self {
214        self.config.generate_structs = false;
215        self
216    }
217
218    /// Set the models module name
219    pub fn models_module(mut self, name: &str) -> Self {
220        self.config.models_module = name.to_string();
221        self
222    }
223
224    /// Set the DAO module name
225    pub fn dao_module(mut self, name: &str) -> Self {
226        self.config.dao_module = name.to_string();
227        self
228    }
229
230    /// Enable dry run mode (preview without writing files)
231    pub fn dry_run(mut self) -> Self {
232        self.config.dry_run = true;
233        self
234    }
235
236    /// Generate the code
237    pub fn generate(self) -> Result<()> {
238        generate(&self.config)
239    }
240}
241
242/// Configuration for `[package.metadata.rdbi-codegen]` in Cargo.toml
243#[derive(Debug, Clone, Default, serde::Deserialize)]
244struct CargoMetadataConfig {
245    /// Path to the SQL schema file (required)
246    schema_file: Option<String>,
247
248    /// Tables to include (optional, defaults to all)
249    #[serde(default)]
250    include_tables: Vec<String>,
251
252    /// Tables to exclude (optional)
253    #[serde(default)]
254    exclude_tables: Vec<String>,
255
256    /// Whether to generate struct files (default: true)
257    generate_structs: Option<bool>,
258
259    /// Whether to generate DAO files (default: true)
260    generate_dao: Option<bool>,
261
262    /// Output directory for generated structs
263    output_structs_dir: Option<String>,
264
265    /// Output directory for generated DAOs
266    output_dao_dir: Option<String>,
267}
268
269#[derive(Debug, serde::Deserialize)]
270struct CargoToml {
271    package: Option<CargoPackage>,
272}
273
274#[derive(Debug, serde::Deserialize)]
275struct CargoPackage {
276    metadata: Option<CargoPackageMetadata>,
277}
278
279#[derive(Debug, serde::Deserialize)]
280struct CargoPackageMetadata {
281    #[serde(rename = "rdbi-codegen")]
282    rdbi_codegen: Option<CargoMetadataConfig>,
283}
284
285/// Generate code from `[package.metadata.rdbi-codegen]` in Cargo.toml
286///
287/// This function reads configuration from the downstream project's Cargo.toml,
288/// making build.rs minimal:
289///
290/// ```rust,ignore
291/// // build.rs
292/// fn main() {
293///     rdbi_codegen::generate_from_cargo_metadata()
294///         .expect("Failed to generate rdbi code");
295/// }
296/// ```
297///
298/// Configure in Cargo.toml:
299///
300/// ```toml
301/// [package.metadata.rdbi-codegen]
302/// schema_file = "schema.sql"
303/// include_tables = ["users", "orders"]
304/// exclude_tables = ["migrations"]
305/// ```
306pub fn generate_from_cargo_metadata() -> Result<()> {
307    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
308        CodegenError::ConfigError(
309            "CARGO_MANIFEST_DIR not set - are you running from build.rs?".into(),
310        )
311    })?;
312
313    let cargo_toml_path = PathBuf::from(&manifest_dir).join("Cargo.toml");
314    let cargo_toml_content = std::fs::read_to_string(&cargo_toml_path)?;
315
316    let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content).map_err(|e| {
317        CodegenError::ConfigError(format!(
318            "Failed to parse {}: {}",
319            cargo_toml_path.display(),
320            e
321        ))
322    })?;
323
324    let metadata_config = cargo_toml
325        .package
326        .and_then(|p| p.metadata)
327        .and_then(|m| m.rdbi_codegen)
328        .ok_or_else(|| {
329            CodegenError::ConfigError(
330                "Missing [package.metadata.rdbi-codegen] section in Cargo.toml".into(),
331            )
332        })?;
333
334    let schema_file = metadata_config.schema_file.ok_or_else(|| {
335        CodegenError::ConfigError(
336            "schema_file is required in [package.metadata.rdbi-codegen]".into(),
337        )
338    })?;
339
340    // Resolve schema_file relative to manifest dir
341    let schema_path = PathBuf::from(&manifest_dir).join(&schema_file);
342
343    // Determine output directory (default to OUT_DIR)
344    let out_dir = std::env::var("OUT_DIR").map(PathBuf::from).map_err(|_| {
345        CodegenError::ConfigError("OUT_DIR not set - are you running from build.rs?".into())
346    })?;
347
348    let mut builder = CodegenBuilder::new(&schema_path);
349
350    // Set output directories and auto-derive models_module from output path
351    if let Some(ref structs_dir) = metadata_config.output_structs_dir {
352        builder = builder.output_structs_dir(PathBuf::from(&manifest_dir).join(structs_dir));
353
354        // Derive models_module from output_structs_dir path.
355        // e.g., "src/generated/models" -> "generated::models"
356        let module_path = structs_dir
357            .strip_prefix("src/")
358            .unwrap_or(structs_dir)
359            .replace('/', "::");
360        builder = builder.models_module(&module_path);
361    } else {
362        builder = builder.output_structs_dir(out_dir.join("models"));
363    }
364
365    if let Some(ref dao_dir) = metadata_config.output_dao_dir {
366        builder = builder.output_dao_dir(PathBuf::from(&manifest_dir).join(dao_dir));
367
368        // Derive dao_module from output_dao_dir path.
369        // e.g., "src/generated/dao" -> "generated::dao"
370        let module_path = dao_dir
371            .strip_prefix("src/")
372            .unwrap_or(dao_dir)
373            .replace('/', "::");
374        builder = builder.dao_module(&module_path);
375    } else {
376        builder = builder.output_dao_dir(out_dir.join("dao"));
377    }
378
379    // Apply table filters
380    if !metadata_config.include_tables.is_empty() {
381        let tables: Vec<&str> = metadata_config
382            .include_tables
383            .iter()
384            .map(|s| s.as_str())
385            .collect();
386        builder = builder.include_tables(&tables);
387    }
388    if !metadata_config.exclude_tables.is_empty() {
389        let tables: Vec<&str> = metadata_config
390            .exclude_tables
391            .iter()
392            .map(|s| s.as_str())
393            .collect();
394        builder = builder.exclude_tables(&tables);
395    }
396
397    // Apply generation options
398    if let Some(false) = metadata_config.generate_structs {
399        builder = builder.dao_only();
400    }
401    if let Some(false) = metadata_config.generate_dao {
402        builder = builder.structs_only();
403    }
404
405    // Emit rerun-if-changed
406    println!("cargo:rerun-if-changed={}", schema_path.display());
407    println!("cargo:rerun-if-changed={}", cargo_toml_path.display());
408
409    builder.generate()
410}