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