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    info!("Code generation complete");
97    Ok(())
98}
99
100/// Filter tables based on include/exclude patterns
101fn filter_tables(
102    tables: Vec<parser::TableMetadata>,
103    include: &str,
104    exclude: &str,
105) -> Vec<parser::TableMetadata> {
106    let include_all = include.trim() == "*" || include.trim().is_empty();
107    let include_set: HashSet<String> = if include_all {
108        HashSet::new()
109    } else {
110        include.split(',').map(|s| s.trim().to_string()).collect()
111    };
112    let exclude_set: HashSet<String> = exclude
113        .split(',')
114        .map(|s| s.trim().to_string())
115        .filter(|s| !s.is_empty())
116        .collect();
117
118    tables
119        .into_iter()
120        .filter(|t| {
121            let name = &t.name;
122            let included = include_all || include_set.contains(name);
123            let excluded = exclude_set.contains(name);
124            included && !excluded
125        })
126        .collect()
127}
128
129/// Builder pattern for easy configuration in build.rs
130pub struct CodegenBuilder {
131    config: CodegenConfig,
132}
133
134impl CodegenBuilder {
135    /// Create a new builder with the given schema file
136    pub fn new(schema_file: impl AsRef<Path>) -> Self {
137        Self {
138            config: CodegenConfig::default_with_schema(schema_file.as_ref().to_path_buf()),
139        }
140    }
141
142    /// Set the output directory for both structs and DAOs
143    pub fn output_dir(mut self, dir: impl AsRef<Path>) -> Self {
144        let dir = dir.as_ref();
145        self.config.output_structs_dir = dir.join("models");
146        self.config.output_dao_dir = dir.join("dao");
147        self
148    }
149
150    /// Set the output directory for structs only
151    pub fn output_structs_dir(mut self, dir: impl AsRef<Path>) -> Self {
152        self.config.output_structs_dir = dir.as_ref().to_path_buf();
153        self
154    }
155
156    /// Set the output directory for DAOs only
157    pub fn output_dao_dir(mut self, dir: impl AsRef<Path>) -> Self {
158        self.config.output_dao_dir = dir.as_ref().to_path_buf();
159        self
160    }
161
162    /// Set tables to include (comma-separated or array)
163    pub fn include_tables(mut self, tables: &[&str]) -> Self {
164        self.config.include_tables = tables.join(",");
165        self
166    }
167
168    /// Set tables to exclude (comma-separated or array)
169    pub fn exclude_tables(mut self, tables: &[&str]) -> Self {
170        self.config.exclude_tables = tables.join(",");
171        self
172    }
173
174    /// Generate only structs, no DAOs
175    pub fn structs_only(mut self) -> Self {
176        self.config.generate_dao = false;
177        self
178    }
179
180    /// Generate only DAOs, no structs
181    pub fn dao_only(mut self) -> Self {
182        self.config.generate_structs = false;
183        self
184    }
185
186    /// Set the models module name
187    pub fn models_module(mut self, name: &str) -> Self {
188        self.config.models_module = name.to_string();
189        self
190    }
191
192    /// Set the DAO module name
193    pub fn dao_module(mut self, name: &str) -> Self {
194        self.config.dao_module = name.to_string();
195        self
196    }
197
198    /// Enable dry run mode (preview without writing files)
199    pub fn dry_run(mut self) -> Self {
200        self.config.dry_run = true;
201        self
202    }
203
204    /// Generate the code
205    pub fn generate(self) -> Result<()> {
206        generate(&self.config)
207    }
208}
209
210/// Configuration for `[package.metadata.rdbi-codegen]` in Cargo.toml
211#[derive(Debug, Clone, Default, serde::Deserialize)]
212struct CargoMetadataConfig {
213    /// Path to the SQL schema file (required)
214    schema_file: Option<String>,
215
216    /// Tables to include (optional, defaults to all)
217    #[serde(default)]
218    include_tables: Vec<String>,
219
220    /// Tables to exclude (optional)
221    #[serde(default)]
222    exclude_tables: Vec<String>,
223
224    /// Whether to generate struct files (default: true)
225    generate_structs: Option<bool>,
226
227    /// Whether to generate DAO files (default: true)
228    generate_dao: Option<bool>,
229
230    /// Output directory for generated structs
231    output_structs_dir: Option<String>,
232
233    /// Output directory for generated DAOs
234    output_dao_dir: Option<String>,
235}
236
237#[derive(Debug, serde::Deserialize)]
238struct CargoToml {
239    package: Option<CargoPackage>,
240}
241
242#[derive(Debug, serde::Deserialize)]
243struct CargoPackage {
244    metadata: Option<CargoPackageMetadata>,
245}
246
247#[derive(Debug, serde::Deserialize)]
248struct CargoPackageMetadata {
249    #[serde(rename = "rdbi-codegen")]
250    rdbi_codegen: Option<CargoMetadataConfig>,
251}
252
253/// Generate code from `[package.metadata.rdbi-codegen]` in Cargo.toml
254///
255/// This function reads configuration from the downstream project's Cargo.toml,
256/// making build.rs minimal:
257///
258/// ```rust,ignore
259/// // build.rs
260/// fn main() {
261///     rdbi_codegen::generate_from_cargo_metadata()
262///         .expect("Failed to generate rdbi code");
263/// }
264/// ```
265///
266/// Configure in Cargo.toml:
267///
268/// ```toml
269/// [package.metadata.rdbi-codegen]
270/// schema_file = "schema.sql"
271/// include_tables = ["users", "orders"]
272/// exclude_tables = ["migrations"]
273/// ```
274pub fn generate_from_cargo_metadata() -> Result<()> {
275    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
276        CodegenError::ConfigError(
277            "CARGO_MANIFEST_DIR not set - are you running from build.rs?".into(),
278        )
279    })?;
280
281    let cargo_toml_path = PathBuf::from(&manifest_dir).join("Cargo.toml");
282    let cargo_toml_content = std::fs::read_to_string(&cargo_toml_path)?;
283
284    let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content).map_err(|e| {
285        CodegenError::ConfigError(format!(
286            "Failed to parse {}: {}",
287            cargo_toml_path.display(),
288            e
289        ))
290    })?;
291
292    let metadata_config = cargo_toml
293        .package
294        .and_then(|p| p.metadata)
295        .and_then(|m| m.rdbi_codegen)
296        .ok_or_else(|| {
297            CodegenError::ConfigError(
298                "Missing [package.metadata.rdbi-codegen] section in Cargo.toml".into(),
299            )
300        })?;
301
302    let schema_file = metadata_config.schema_file.ok_or_else(|| {
303        CodegenError::ConfigError(
304            "schema_file is required in [package.metadata.rdbi-codegen]".into(),
305        )
306    })?;
307
308    // Resolve schema_file relative to manifest dir
309    let schema_path = PathBuf::from(&manifest_dir).join(&schema_file);
310
311    // Determine output directory (default to OUT_DIR)
312    let out_dir = std::env::var("OUT_DIR").map(PathBuf::from).map_err(|_| {
313        CodegenError::ConfigError("OUT_DIR not set - are you running from build.rs?".into())
314    })?;
315
316    let mut builder = CodegenBuilder::new(&schema_path);
317
318    // Set output directories and auto-derive models_module from output path
319    if let Some(ref structs_dir) = metadata_config.output_structs_dir {
320        builder = builder.output_structs_dir(PathBuf::from(&manifest_dir).join(structs_dir));
321
322        // Derive models_module from output_structs_dir path.
323        // e.g., "src/generated/models" -> "generated::models"
324        let module_path = structs_dir
325            .strip_prefix("src/")
326            .unwrap_or(structs_dir)
327            .replace('/', "::");
328        builder = builder.models_module(&module_path);
329    } else {
330        builder = builder.output_structs_dir(out_dir.join("models"));
331    }
332
333    if let Some(ref dao_dir) = metadata_config.output_dao_dir {
334        builder = builder.output_dao_dir(PathBuf::from(&manifest_dir).join(dao_dir));
335
336        // Derive dao_module from output_dao_dir path.
337        // e.g., "src/generated/dao" -> "generated::dao"
338        let module_path = dao_dir
339            .strip_prefix("src/")
340            .unwrap_or(dao_dir)
341            .replace('/', "::");
342        builder = builder.dao_module(&module_path);
343    } else {
344        builder = builder.output_dao_dir(out_dir.join("dao"));
345    }
346
347    // Apply table filters
348    if !metadata_config.include_tables.is_empty() {
349        let tables: Vec<&str> = metadata_config
350            .include_tables
351            .iter()
352            .map(|s| s.as_str())
353            .collect();
354        builder = builder.include_tables(&tables);
355    }
356    if !metadata_config.exclude_tables.is_empty() {
357        let tables: Vec<&str> = metadata_config
358            .exclude_tables
359            .iter()
360            .map(|s| s.as_str())
361            .collect();
362        builder = builder.exclude_tables(&tables);
363    }
364
365    // Apply generation options
366    if let Some(false) = metadata_config.generate_structs {
367        builder = builder.dao_only();
368    }
369    if let Some(false) = metadata_config.generate_dao {
370        builder = builder.structs_only();
371    }
372
373    // Emit rerun-if-changed
374    println!("cargo:rerun-if-changed={}", schema_path.display());
375    println!("cargo:rerun-if-changed={}", cargo_toml_path.display());
376
377    builder.generate()
378}