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//! include_tables = ["users", "orders"]
17//! exclude_tables = ["migrations"]
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//! # Alternative: Programmatic Configuration
30//!
31//! ```rust,ignore
32//! use std::env;
33//! use std::path::PathBuf;
34//!
35//! fn main() {
36//!     let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
37//!
38//!     rdbi_codegen::CodegenBuilder::new("schema.sql")
39//!         .output_dir(&out_dir)
40//!         .generate()
41//!         .expect("Failed to generate rdbi code");
42//!
43//!     println!("cargo:rerun-if-changed=schema.sql");
44//! }
45//! ```
46//!
47//! # CLI Usage
48//!
49//! ```bash
50//! rdbi-codegen --schema schema.sql --output ./generated
51//! ```
52
53pub mod codegen;
54pub mod config;
55pub mod error;
56pub mod parser;
57
58use std::collections::HashSet;
59use std::path::{Path, PathBuf};
60
61use tracing::{debug, info};
62
63pub use config::CodegenConfig;
64pub use error::{CodegenError, Result};
65
66/// Main entry point for code generation
67pub fn generate(config: &CodegenConfig) -> Result<()> {
68    info!("Parsing schema: {:?}", config.schema_file);
69    let schema_sql = std::fs::read_to_string(&config.schema_file)?;
70    let tables = parser::parse_schema(&schema_sql)?;
71    info!("Found {} tables", tables.len());
72
73    let tables = filter_tables(tables, &config.include_tables, &config.exclude_tables);
74    debug!(
75        "After filtering: {} tables (include={}, exclude={})",
76        tables.len(),
77        config.include_tables,
78        config.exclude_tables
79    );
80
81    if config.generate_structs {
82        info!("Generating structs in {:?}", config.output_structs_dir);
83        codegen::generate_structs(&tables, config)?;
84    }
85    if config.generate_dao {
86        info!("Generating DAOs in {:?}", config.output_dao_dir);
87        codegen::generate_daos(&tables, config)?;
88    }
89
90    info!("Code generation complete");
91    Ok(())
92}
93
94/// Filter tables based on include/exclude patterns
95fn filter_tables(
96    tables: Vec<parser::TableMetadata>,
97    include: &str,
98    exclude: &str,
99) -> Vec<parser::TableMetadata> {
100    let include_all = include.trim() == "*" || include.trim().is_empty();
101    let include_set: HashSet<String> = if include_all {
102        HashSet::new()
103    } else {
104        include.split(',').map(|s| s.trim().to_string()).collect()
105    };
106    let exclude_set: HashSet<String> = exclude
107        .split(',')
108        .map(|s| s.trim().to_string())
109        .filter(|s| !s.is_empty())
110        .collect();
111
112    tables
113        .into_iter()
114        .filter(|t| {
115            let name = &t.name;
116            let included = include_all || include_set.contains(name);
117            let excluded = exclude_set.contains(name);
118            included && !excluded
119        })
120        .collect()
121}
122
123/// Builder pattern for easy configuration in build.rs
124pub struct CodegenBuilder {
125    config: CodegenConfig,
126}
127
128impl CodegenBuilder {
129    /// Create a new builder with the given schema file
130    pub fn new(schema_file: impl AsRef<Path>) -> Self {
131        Self {
132            config: CodegenConfig::default_with_schema(schema_file.as_ref().to_path_buf()),
133        }
134    }
135
136    /// Set the output directory for both structs and DAOs
137    pub fn output_dir(mut self, dir: impl AsRef<Path>) -> Self {
138        let dir = dir.as_ref();
139        self.config.output_structs_dir = dir.join("models");
140        self.config.output_dao_dir = dir.join("dao");
141        self
142    }
143
144    /// Set the output directory for structs only
145    pub fn output_structs_dir(mut self, dir: impl AsRef<Path>) -> Self {
146        self.config.output_structs_dir = dir.as_ref().to_path_buf();
147        self
148    }
149
150    /// Set the output directory for DAOs only
151    pub fn output_dao_dir(mut self, dir: impl AsRef<Path>) -> Self {
152        self.config.output_dao_dir = dir.as_ref().to_path_buf();
153        self
154    }
155
156    /// Set tables to include (comma-separated or array)
157    pub fn include_tables(mut self, tables: &[&str]) -> Self {
158        self.config.include_tables = tables.join(",");
159        self
160    }
161
162    /// Set tables to exclude (comma-separated or array)
163    pub fn exclude_tables(mut self, tables: &[&str]) -> Self {
164        self.config.exclude_tables = tables.join(",");
165        self
166    }
167
168    /// Generate only structs, no DAOs
169    pub fn structs_only(mut self) -> Self {
170        self.config.generate_dao = false;
171        self
172    }
173
174    /// Generate only DAOs, no structs
175    pub fn dao_only(mut self) -> Self {
176        self.config.generate_structs = false;
177        self
178    }
179
180    /// Set the models module name
181    pub fn models_module(mut self, name: &str) -> Self {
182        self.config.models_module = name.to_string();
183        self
184    }
185
186    /// Set the DAO module name
187    pub fn dao_module(mut self, name: &str) -> Self {
188        self.config.dao_module = name.to_string();
189        self
190    }
191
192    /// Enable dry run mode (preview without writing files)
193    pub fn dry_run(mut self) -> Self {
194        self.config.dry_run = true;
195        self
196    }
197
198    /// Generate the code
199    pub fn generate(self) -> Result<()> {
200        generate(&self.config)
201    }
202}
203
204/// Configuration for `[package.metadata.rdbi-codegen]` in Cargo.toml
205#[derive(Debug, Clone, Default, serde::Deserialize)]
206struct CargoMetadataConfig {
207    /// Path to the SQL schema file (required)
208    schema_file: Option<String>,
209
210    /// Tables to include (optional, defaults to all)
211    #[serde(default)]
212    include_tables: Vec<String>,
213
214    /// Tables to exclude (optional)
215    #[serde(default)]
216    exclude_tables: Vec<String>,
217
218    /// Whether to generate struct files (default: true)
219    generate_structs: Option<bool>,
220
221    /// Whether to generate DAO files (default: true)
222    generate_dao: Option<bool>,
223
224    /// Output directory for generated structs
225    output_structs_dir: Option<String>,
226
227    /// Output directory for generated DAOs
228    output_dao_dir: Option<String>,
229
230    /// Module name for structs (default: "models")
231    models_module: Option<String>,
232
233    /// Module name for DAOs (default: "dao")
234    dao_module: 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
319    if let Some(structs_dir) = metadata_config.output_structs_dir {
320        builder = builder.output_structs_dir(PathBuf::from(&manifest_dir).join(structs_dir));
321    } else {
322        builder = builder.output_structs_dir(out_dir.join("models"));
323    }
324
325    if let Some(dao_dir) = metadata_config.output_dao_dir {
326        builder = builder.output_dao_dir(PathBuf::from(&manifest_dir).join(dao_dir));
327    } else {
328        builder = builder.output_dao_dir(out_dir.join("dao"));
329    }
330
331    // Apply table filters
332    if !metadata_config.include_tables.is_empty() {
333        let tables: Vec<&str> = metadata_config
334            .include_tables
335            .iter()
336            .map(|s| s.as_str())
337            .collect();
338        builder = builder.include_tables(&tables);
339    }
340    if !metadata_config.exclude_tables.is_empty() {
341        let tables: Vec<&str> = metadata_config
342            .exclude_tables
343            .iter()
344            .map(|s| s.as_str())
345            .collect();
346        builder = builder.exclude_tables(&tables);
347    }
348
349    // Apply generation options
350    if let Some(false) = metadata_config.generate_structs {
351        builder = builder.dao_only();
352    }
353    if let Some(false) = metadata_config.generate_dao {
354        builder = builder.structs_only();
355    }
356
357    // Apply module names
358    if let Some(module) = metadata_config.models_module {
359        builder = builder.models_module(&module);
360    }
361    if let Some(module) = metadata_config.dao_module {
362        builder = builder.dao_module(&module);
363    }
364
365    // Emit rerun-if-changed
366    println!("cargo:rerun-if-changed={}", schema_path.display());
367    println!("cargo:rerun-if-changed={}", cargo_toml_path.display());
368
369    builder.generate()
370}