Skip to main content

rullst_orm/
schema.rs

1use crate::Error;
2
3/// Allowlist of SQL comparison/join operators accepted in raw clause builders.
4const ALLOWED_OPERATORS: &[&str] = &["=", "!=", "<>", "<", ">", "<=", ">="];
5
6/// Validates a SQL identifier (column or table name) to prevent SQL injection.
7/// Allows alphanumeric characters, underscores, hyphens and a single dot
8/// for qualified names like `table.column`.
9pub fn validate_identifier(name: &str) -> Result<(), Error> {
10    if name.is_empty() {
11        return Err(Error::Internal(
12            "SQL identifier cannot be empty".to_string(),
13        ));
14    }
15    // At most one dot is allowed (for `table.column` notation),
16    // and it must not be the first or last character.
17    let dot_count = name.chars().filter(|&c| c == '.').count();
18    if dot_count > 1 {
19        return Err(Error::Internal(format!(
20            "Invalid SQL identifier '{}': at most one dot is allowed",
21            name
22        )));
23    }
24    if name.starts_with('.') || name.ends_with('.') {
25        return Err(Error::Internal(format!(
26            "Invalid SQL identifier '{}': must not start or end with a dot",
27            name
28        )));
29    }
30    if !name
31        .chars()
32        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
33    {
34        return Err(Error::Internal(format!(
35            "Invalid SQL identifier '{}': only alphanumeric characters, underscores, hyphens and dots are allowed",
36            name
37        )));
38    }
39    Ok(())
40}
41
42/// Validates a table name to prevent SQL injection.
43/// Wraps `validate_identifier` but disallows dots (table names have no qualifier).
44fn validate_table_name(table_name: &str) -> Result<(), Error> {
45    if table_name.contains('.') {
46        return Err(Error::Internal(format!(
47            "Invalid table name '{}': dots are not allowed in table names",
48            table_name
49        )));
50    }
51    validate_identifier(table_name)
52}
53
54/// Safe values allowed for a column DEFAULT clause.
55///
56/// Accepting a raw `&str` would allow DDL injection through the DEFAULT
57/// position. This enum restricts callers to known-safe literals.
58#[derive(Debug, Clone, PartialEq)]
59pub enum ColumnDefault {
60    /// `CURRENT_TIMESTAMP` — standard SQL timestamp literal.
61    CurrentTimestamp,
62    /// `NULL` — explicit SQL null default.
63    Null,
64    /// A non-negative integer literal (e.g. `0`, `1`).
65    Integer(i64),
66    /// A non-negative real literal (e.g. `0.0`).
67    Float(f64),
68    /// A string literal that will be single-quoted and escaped.
69    /// Only printable ASCII excluding `'` and `\` is accepted.
70    Text(String),
71}
72
73impl ColumnDefault {
74    /// Renders the default value as a safe SQL fragment.
75    pub fn to_sql(&self) -> String {
76        match self {
77            ColumnDefault::CurrentTimestamp => "CURRENT_TIMESTAMP".to_string(),
78            ColumnDefault::Null => "NULL".to_string(),
79            ColumnDefault::Integer(n) => n.to_string(),
80            ColumnDefault::Float(f) => format!("{f}"),
81            // Single-quote the string and escape any embedded single-quotes
82            // via SQL standard doubling (''), which is safe on every driver.
83            ColumnDefault::Text(s) => format!("'{}'", s.replace('\'', "''")),
84        }
85    }
86}
87
88pub struct Column {
89    pub name: String,
90    pub col_type: String,
91    pub is_nullable: bool,
92    pub is_primary_key: bool,
93    pub is_auto_increment: bool,
94    pub default_value: Option<ColumnDefault>,
95}
96
97impl Column {
98    /// Creates a new column, validating `name` against SQL identifier rules.
99    ///
100    /// # Panics
101    /// Panics if `name` fails identifier validation. Column names are always
102    /// developer-supplied compile-time literals — an invalid name is a bug,
103    /// not a runtime condition.
104    pub fn new(name: &str, col_type: &str) -> Self {
105        validate_identifier(name)
106            .unwrap_or_else(|e| panic!("Invalid column name {:?}: {}", name, e));
107        Self {
108            name: name.to_string(),
109            col_type: col_type.to_string(),
110            is_nullable: true,
111            is_primary_key: false,
112            is_auto_increment: false,
113            default_value: None,
114        }
115    }
116
117    pub fn not_null(&mut self) -> &mut Self {
118        self.is_nullable = false;
119        self
120    }
121
122    pub fn nullable(&mut self) -> &mut Self {
123        self.is_nullable = true;
124        self
125    }
126
127    /// Sets a safe DEFAULT value using the [`ColumnDefault`] enum.
128    ///
129    /// The old `&str` overload has been removed to prevent DDL injection
130    /// through unescaped DEFAULT clauses.
131    pub fn default(&mut self, val: ColumnDefault) -> &mut Self {
132        self.default_value = Some(val);
133        self
134    }
135
136    pub fn primary(&mut self) -> &mut Self {
137        self.is_primary_key = true;
138        self
139    }
140}
141
142pub struct Blueprint {
143    pub columns: Vec<Column>,
144}
145
146impl Default for Blueprint {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl Blueprint {
153    pub fn new() -> Self {
154        Self { columns: vec![] }
155    }
156
157    pub fn id(&mut self) -> &mut Column {
158        self.columns.push(Column {
159            name: "id".to_string(),
160            col_type: "INTEGER".to_string(),
161            is_nullable: false,
162            is_primary_key: true,
163            is_auto_increment: true,
164            default_value: None,
165        });
166        self.columns
167            .last_mut()
168            .expect("BUG: columns is empty after push")
169    }
170
171    fn add_column(&mut self, name: &str, col_type: &str) -> &mut Column {
172        let col = Column::new(name, col_type);
173        self.columns.push(col);
174        self.columns
175            .last_mut()
176            .expect("BUG: columns is empty after push")
177    }
178
179    pub fn string(&mut self, name: &str) -> &mut Column {
180        self.add_column(name, "TEXT")
181    }
182
183    pub fn integer(&mut self, name: &str) -> &mut Column {
184        self.add_column(name, "INTEGER")
185    }
186
187    pub fn float(&mut self, name: &str) -> &mut Column {
188        self.add_column(name, "REAL")
189    }
190
191    pub fn boolean(&mut self, name: &str) -> &mut Column {
192        self.add_column(name, "INTEGER")
193    }
194
195    pub fn timestamps(&mut self) {
196        let mut created = Column::new("created_at", "TEXT");
197        created.default(ColumnDefault::CurrentTimestamp);
198        self.columns.push(created);
199
200        let mut updated = Column::new("updated_at", "TEXT");
201        updated.default(ColumnDefault::CurrentTimestamp);
202        self.columns.push(updated);
203    }
204
205    pub fn soft_deletes(&mut self) {
206        let col = Column::new("deleted_at", "TEXT");
207        self.columns.push(col);
208        self.columns
209            .last_mut()
210            .expect("BUG: columns is empty after push")
211            .nullable();
212    }
213
214    pub fn build(&self) -> Result<String, Error> {
215        let mut defs = vec![];
216        for col in &self.columns {
217            // Defensive re-validation: column names must always be safe
218            // identifiers regardless of how the Column was constructed.
219            validate_identifier(&col.name)?;
220            let mut def = format!("{} {}", col.name, col.col_type);
221            if col.is_primary_key {
222                def.push_str(" PRIMARY KEY");
223            }
224            if col.is_auto_increment {
225                def.push_str(" AUTOINCREMENT");
226            }
227            if !col.is_nullable && !col.is_primary_key {
228                def.push_str(" NOT NULL");
229            }
230            if let Some(default) = &col.default_value {
231                use std::fmt::Write;
232                write!(def, " DEFAULT {}", default.to_sql()).unwrap();
233            }
234            defs.push(def);
235        }
236        Ok(defs.join(",\n    "))
237    }
238}
239
240pub struct Schema;
241
242impl Schema {
243    pub async fn create<F>(table_name: &str, callback: F) -> Result<(), Error>
244    where
245        F: FnOnce(&mut Blueprint),
246    {
247        validate_table_name(table_name)?;
248
249        let mut blueprint = Blueprint::new();
250        callback(&mut blueprint);
251
252        // build() now returns Result so any column-name or default issues
253        // surface as errors rather than producing malformed SQL.
254        let columns_sql = blueprint.build()?;
255        let sql = format!(
256            "CREATE TABLE IF NOT EXISTS {} (\n    {}\n);",
257            table_name, columns_sql
258        );
259
260        let pool = crate::Orm::pool();
261        let mut query_builder = sqlx::query_builder::QueryBuilder::new("");
262        query_builder.push(&sql);
263        query_builder.build().execute(pool).await?;
264
265        Ok(())
266    }
267
268    pub async fn drop_if_exists(table_name: &str) -> Result<(), Error> {
269        validate_table_name(table_name)?;
270
271        let sql = format!("DROP TABLE IF EXISTS {};", table_name);
272        let pool = crate::Orm::pool();
273        let mut query_builder = sqlx::query_builder::QueryBuilder::new("");
274        query_builder.push(&sql);
275        query_builder.build().execute(pool).await?;
276        Ok(())
277    }
278}
279
280#[async_trait::async_trait]
281pub trait Migration: Send + Sync {
282    fn name(&self) -> &'static str;
283    async fn up(&self) -> Result<(), Error>;
284    async fn down(&self) -> Result<(), Error>;
285}
286
287pub async fn run_artisan_with_args(
288    args: &[String],
289    migrations: Vec<Box<dyn Migration>>,
290    seeders: Vec<Box<dyn crate::Seeder>>,
291) -> Result<(), Error> {
292    if args.len() < 2 {
293        println!("Rullst ORM Artisan CLI");
294        println!("Usage:");
295        println!("  make:migration <name>   Generate a new migration");
296        println!("  migrate                  Run all pending migrations");
297        println!("  migrate:rollback         Rollback the last batch of migrations");
298        println!("  status                   Show migrations status");
299        println!("  db:seed                  Populate the database with seeders");
300        return Ok(());
301    }
302
303    let command = &args[1];
304    match command.as_str() {
305        "make:migration" => {
306            if args.len() < 3 {
307                println!("Error: migration name is required.");
308                return Ok(());
309            }
310            let name = &args[2];
311            create_migration_files(name)?;
312        }
313        "migrate" | "db:migrate" => {
314            run_migrations(migrations).await?;
315        }
316        "migrate:rollback" | "db:rollback" => {
317            rollback_migrations(migrations).await?;
318        }
319        "status" | "db:status" => {
320            status_migrations(migrations).await?;
321        }
322        "db:seed" => {
323            println!("Seeding database...");
324            crate::Orm::seed(seeders).await?;
325            println!("Database seeded successfully!");
326        }
327        _ => {
328            println!("Unknown command: {}", command);
329        }
330    }
331    Ok(())
332}
333
334pub async fn run_artisan(
335    migrations: Vec<Box<dyn Migration>>,
336    seeders: Vec<Box<dyn crate::Seeder>>,
337) -> Result<(), Error> {
338    let args: Vec<String> = std::env::args().collect();
339    run_artisan_with_args(&args, migrations, seeders).await
340}
341
342async fn status_migrations(migrations: Vec<Box<dyn Migration>>) -> Result<(), Error> {
343    let pool = crate::Orm::pool();
344    let driver = crate::Orm::driver();
345
346    let table_exists = match driver {
347        "postgres" | "mysql" => {
348            let query_str =
349                "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'migrations'";
350            let row: (i64,) = sqlx::query_as(query_str).fetch_one(pool).await?;
351            row.0 > 0
352        }
353        _ => {
354            let query_str =
355                "SELECT COUNT(*) FROM sqlite_schema WHERE type='table' AND name='migrations'";
356            let row: (i64,) = sqlx::query_as(query_str).fetch_one(pool).await?;
357            row.0 > 0
358        }
359    };
360
361    let executed_set = if table_exists {
362        let executed: Vec<(String,)> = sqlx::query_as("SELECT migration FROM migrations")
363            .fetch_all(pool)
364            .await?;
365        executed
366            .into_iter()
367            .map(|(m,)| m)
368            .collect::<std::collections::HashSet<String>>()
369    } else {
370        std::collections::HashSet::new()
371    };
372
373    let name_header = "Migration Name";
374    let status_header = "Status";
375    println!("{name_header:<40} | {status_header}");
376    println!("{}", "-".repeat(55));
377    for m in migrations {
378        let name = m.name();
379        let status = if executed_set.contains(name) {
380            "Applied"
381        } else {
382            "Pending"
383        };
384        println!("{:<40} | {}", name, status);
385    }
386
387    Ok(())
388}
389
390fn create_migration_files(name: &str) -> Result<(), Error> {
391    validate_table_name(name)?;
392    use std::fs;
393
394    let now = std::time::SystemTime::now()
395        .duration_since(std::time::UNIX_EPOCH)
396        .expect("System time went backwards")
397        .as_secs()
398        .to_string();
399    let snake_name = name.to_lowercase().replace("-", "_");
400    let file_name = format!("m{}_{}", now, snake_name);
401
402    fs::create_dir_all("src/migrations")
403        .map_err(|e| Error::Internal(format!("Failed to create migrations directory: {}", e)))?;
404
405    let new_file_path = format!("src/migrations/{}.rs", file_name);
406    let migration_code = format!(
407        r#"use rullst_orm::schema::{{Schema, Blueprint, Migration}};
408use rullst_orm::async_trait;
409
410pub struct MigrationImpl;
411
412#[async_trait]
413impl Migration for MigrationImpl {{
414    fn name(&self) -> &'static str {{
415        "m{timestamp}_{name}"
416    }}
417
418    async fn up(&self) -> Result<(), crate::Error> {{
419        Schema::create("{name}", |table| {{
420            table.id();
421            table.timestamps();
422        }}).await
423    }}
424
425    async fn down(&self) -> Result<(), crate::Error> {{
426        Schema::drop_if_exists("{name}").await
427    }}
428}}
429"#,
430        timestamp = now,
431        name = snake_name
432    );
433
434    fs::write(&new_file_path, migration_code)
435        .map_err(|e| Error::Internal(format!("Failed to write migration file: {}", e)))?;
436    println!("Created migration file: {}", new_file_path);
437
438    regenerate_migrations_mod()?;
439
440    Ok(())
441}
442
443fn regenerate_migrations_mod() -> Result<(), Error> {
444    use std::fs;
445    let paths = fs::read_dir("src/migrations")
446        .map_err(|e| Error::Internal(format!("Failed to read migrations dir: {}", e)))?;
447
448    let mut modules = vec![];
449    for path in paths {
450        let path = path.map_err(|e| Error::Internal(e.to_string()))?.path();
451        if let Some(ext) = path.extension()
452            && ext == "rs"
453            && let Some(stem) = path.file_stem()
454        {
455            let stem_str = stem.to_string_lossy().to_string();
456            if stem_str != "mod" && stem_str.starts_with('m') {
457                modules.push(stem_str);
458            }
459        }
460    }
461    modules.sort();
462
463    use std::fmt::Write;
464    let mut mod_content = String::new();
465    mod_content.push_str("// Generated by Rullst ORM Artisan. Do not edit manually.\n\n");
466    for m in &modules {
467        writeln!(mod_content, "pub mod {};", m).unwrap();
468    }
469    mod_content
470        .push_str("\npub fn get_migrations() -> Vec<Box<dyn rullst_orm::schema::Migration>> {\n");
471    mod_content.push_str("    vec![\n");
472    for m in &modules {
473        writeln!(mod_content, "        Box::new({}::MigrationImpl),", m).unwrap();
474    }
475    mod_content.push_str("    ]\n");
476    mod_content.push_str("}\n");
477
478    fs::write("src/migrations/mod.rs", mod_content)
479        .map_err(|e| Error::Internal(format!("Failed to write mod.rs: {}", e)))?;
480    println!("Regenerated src/migrations/mod.rs");
481
482    Ok(())
483}
484
485async fn run_migrations(migrations: Vec<Box<dyn Migration>>) -> Result<(), Error> {
486    let pool = crate::Orm::pool();
487    let driver = crate::Orm::driver();
488
489    let query_str = match driver {
490        "postgres" => {
491            "CREATE TABLE IF NOT EXISTS migrations (
492                id SERIAL PRIMARY KEY,
493                migration VARCHAR(255) NOT NULL,
494                batch INTEGER NOT NULL
495            )"
496        }
497        "mysql" => {
498            "CREATE TABLE IF NOT EXISTS migrations (
499                id INT AUTO_INCREMENT PRIMARY KEY,
500                migration VARCHAR(255) NOT NULL,
501                batch INT NOT NULL
502            )"
503        }
504        _ => {
505            "CREATE TABLE IF NOT EXISTS migrations (
506                id INTEGER PRIMARY KEY AUTOINCREMENT,
507                migration TEXT NOT NULL,
508                batch INTEGER NOT NULL
509            )"
510        }
511    };
512
513    sqlx::query(query_str).execute(pool).await?;
514
515    let executed: Vec<(String,)> = sqlx::query_as("SELECT migration FROM migrations")
516        .fetch_all(pool)
517        .await?;
518    let executed_set: std::collections::HashSet<String> =
519        executed.into_iter().map(|(m,)| m).collect();
520
521    let batch_row: (Option<i32>,) = sqlx::query_as("SELECT MAX(batch) FROM migrations")
522        .fetch_one(pool)
523        .await?;
524    let next_batch = batch_row.0.unwrap_or(0) + 1;
525
526    let mut count = 0;
527    let mut successful_migrations = vec![];
528    for m in migrations {
529        let name = m.name();
530        if !executed_set.contains(name) {
531            println!("Migrating: {}", name);
532            m.up().await?;
533            successful_migrations.push(name);
534            println!("Migrated:  {}", name);
535            count += 1;
536        }
537    }
538
539    if count > 0 {
540        let mut query_builder =
541            sqlx::query_builder::QueryBuilder::new("INSERT INTO migrations (migration, batch) ");
542        query_builder.push_values(successful_migrations, |mut b, name| {
543            b.push_bind(name).push_bind(next_batch);
544        });
545        query_builder.build().execute(pool).await?;
546    } else {
547        println!("Nothing to migrate.");
548    }
549
550    Ok(())
551}
552
553async fn rollback_migrations(migrations: Vec<Box<dyn Migration>>) -> Result<(), Error> {
554    let pool = crate::Orm::pool();
555    let driver = crate::Orm::driver();
556
557    let table_exists = match driver {
558        "postgres" | "mysql" => {
559            let query_str =
560                "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'migrations'";
561            let row: (i64,) = sqlx::query_as(query_str).fetch_one(pool).await?;
562            row.0 > 0
563        }
564        _ => {
565            let query_str =
566                "SELECT COUNT(*) FROM sqlite_schema WHERE type='table' AND name='migrations'";
567            let row: (i64,) = sqlx::query_as(query_str).fetch_one(pool).await?;
568            row.0 > 0
569        }
570    };
571
572    if !table_exists {
573        println!("Nothing to rollback.");
574        return Ok(());
575    }
576
577    let batch_row: (Option<i32>,) = sqlx::query_as("SELECT MAX(batch) FROM migrations")
578        .fetch_one(pool)
579        .await?;
580
581    let last_batch = match batch_row.0 {
582        Some(b) if b > 0 => b,
583        _ => {
584            println!("Nothing to rollback.");
585            return Ok(());
586        }
587    };
588
589    let to_rollback: Vec<(String,)> =
590        sqlx::query_as("SELECT migration FROM migrations WHERE batch = ? ORDER BY id DESC")
591            .bind(last_batch)
592            .fetch_all(pool)
593            .await?;
594
595    let mut rollback_map = std::collections::HashMap::with_capacity(migrations.len());
596    for m in migrations {
597        rollback_map.insert(m.name().to_string(), m);
598    }
599
600    for (name,) in to_rollback {
601        if let Some(m) = rollback_map.get(&name) {
602            println!("Rolling back: {}", name);
603            m.down().await?;
604            sqlx::query("DELETE FROM migrations WHERE migration = ?")
605                .bind(&name)
606                .execute(pool)
607                .await?;
608            println!("Rolled back:  {}", name);
609        } else {
610            println!(
611                "Warning: migration {} found in database but not in compiled binary.",
612                name
613            );
614        }
615    }
616
617    Ok(())
618}
619
620pub struct JoinClause {
621    pub table: String,
622    pub conditions: Vec<String>,
623    pub bindings: Vec<crate::RullstValue>,
624    pub errors: Vec<crate::Error>,
625}
626
627impl JoinClause {
628    pub fn new(table: &str) -> Self {
629        Self {
630            table: table.to_string(),
631            conditions: vec![],
632            bindings: vec![],
633            errors: vec![],
634        }
635    }
636
637    /// Adds a column-to-column JOIN condition.
638    ///
639    /// This prevents SQL injection — column names should always be hardcoded, never
640    /// derived from user input. Returns errors internally rather than panicking.
641    pub fn on(&mut self, first: &str, operator: &str, second: &str) -> &mut Self {
642        if let Err(e) = validate_identifier(first) {
643            self.errors.push(crate::Error::Validation(format!(
644                "JoinClause::on — invalid identifier for `first`: {:?}",
645                e
646            )));
647        }
648        if let Err(e) = validate_identifier(second) {
649            self.errors.push(crate::Error::Validation(format!(
650                "JoinClause::on — invalid identifier for `second`: {:?}",
651                e
652            )));
653        }
654        if !ALLOWED_OPERATORS.contains(&operator) {
655            self.errors.push(crate::Error::Validation(format!(
656                "JoinClause::on — invalid operator '{}'. Allowed: {:?}",
657                operator, ALLOWED_OPERATORS
658            )));
659        }
660        self.conditions
661            .push(format!("{} {} {}", first, operator, second));
662        self
663    }
664
665    pub fn on_eq<T: Into<crate::RullstValue>>(&mut self, column: &str, value: T) -> &mut Self {
666        if let Err(e) = validate_identifier(column) {
667            self.errors.push(crate::Error::Validation(format!(
668                "JoinClause::on_eq — invalid identifier for `column`: {:?}",
669                e
670            )));
671        }
672        self.conditions.push(format!("{} = ?", column));
673        self.bindings.push(value.into());
674        self
675    }
676
677    pub fn to_sql(&self) -> String {
678        self.conditions.join(" AND ")
679    }
680}
681
682pub trait SubqueryBuilder {
683    fn to_sql(&self) -> String;
684    fn bindings(&self) -> &Vec<crate::RullstValue>;
685}
686
687pub static QUERY_LOGGING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
688
689pub fn enable_query_log() {
690    QUERY_LOGGING.store(true, std::sync::atomic::Ordering::SeqCst);
691}
692
693pub fn disable_query_log() {
694    QUERY_LOGGING.store(false, std::sync::atomic::Ordering::SeqCst);
695}
696
697pub fn is_query_log_enabled() -> bool {
698    QUERY_LOGGING.load(std::sync::atomic::Ordering::SeqCst)
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    #[test]
706    fn test_enable_disable_query_log() {
707        disable_query_log();
708        assert!(!is_query_log_enabled());
709        enable_query_log();
710        assert!(is_query_log_enabled());
711        disable_query_log();
712        assert!(!is_query_log_enabled());
713    }
714
715    #[test]
716    fn test_join_clause() {
717        let mut jc = JoinClause::new("users");
718        jc.on("users.id", "=", "posts.user_id");
719        assert_eq!(jc.to_sql(), "users.id = posts.user_id");
720    }
721
722    #[test]
723    fn test_validate_table_name() {
724        assert!(validate_table_name("users").is_ok());
725        assert!(validate_table_name("user_posts").is_ok());
726        assert!(validate_table_name("DROP TABLE users").is_err());
727        assert!(validate_table_name("../../../etc/shadow").is_err());
728        // dots not allowed in table names
729        assert!(validate_table_name("users.id").is_err());
730        assert!(validate_table_name("").is_err()); // Empty table name
731    }
732
733    #[test]
734    fn test_validate_identifier() {
735        assert!(validate_identifier("users").is_ok());
736        assert!(validate_identifier("users.id").is_ok());
737        assert!(validate_identifier("user_posts").is_ok());
738        assert!(validate_identifier("").is_err());
739        assert!(validate_identifier("users.posts.id").is_err()); // two dots
740        assert!(validate_identifier("DROP TABLE users").is_err());
741        assert!(validate_identifier("id; DROP TABLE users--").is_err());
742        // Leading/trailing dot edge cases — all now rejected
743        assert!(validate_identifier(".").is_err()); // bare dot: starts AND ends with dot
744        assert!(validate_identifier(".users").is_err()); // leading dot
745        assert!(validate_identifier("users.").is_err()); // trailing dot
746        assert!(validate_identifier("user name").is_err()); // Spaces not allowed
747        assert!(validate_identifier("admin'--").is_err()); // Quotes not allowed
748        assert!(validate_identifier("users()").is_err()); // Parentheses not allowed
749        assert!(validate_identifier("a*b").is_err()); // Asterisk not allowed
750
751        // Extensive error tests
752        assert!(validate_identifier("SELECT * FROM users").is_err());
753        assert!(validate_identifier("users\nWHERE").is_err());
754        assert!(validate_identifier("users\t").is_err());
755        assert!(validate_identifier("\\").is_err());
756    }
757
758    #[test]
759    fn test_join_clause_on_invalid_operator() {
760        let mut jc = JoinClause::new("posts");
761        jc.on("posts.user_id", "OR 1=1 --", "users.id");
762        assert!(!jc.errors.is_empty());
763        assert!(jc.errors[0].to_string().contains("invalid operator"));
764    }
765
766    #[test]
767    fn test_join_clause_on_invalid_column() {
768        let mut jc = JoinClause::new("posts");
769        jc.on("users.id; DROP TABLE users--", "=", "posts.user_id");
770        assert!(!jc.errors.is_empty());
771        assert!(jc.errors[0].to_string().contains("invalid identifier"));
772    }
773
774    #[test]
775    fn test_timestamps_adds_columns() {
776        let mut bp = Blueprint::new();
777        bp.timestamps();
778        assert_eq!(bp.columns.len(), 2);
779        assert_eq!(bp.columns[0].name, "created_at");
780        assert_eq!(bp.columns[1].name, "updated_at");
781        assert_eq!(
782            bp.columns[0].default_value,
783            Some(ColumnDefault::CurrentTimestamp)
784        );
785        assert_eq!(
786            bp.columns[1].default_value,
787            Some(ColumnDefault::CurrentTimestamp)
788        );
789    }
790
791    #[test]
792    fn test_soft_deletes_adds_nullable_column() {
793        let mut bp = Blueprint::new();
794        bp.soft_deletes();
795        assert_eq!(bp.columns.len(), 1);
796        assert_eq!(bp.columns[0].name, "deleted_at");
797        assert!(bp.columns[0].is_nullable);
798    }
799
800    #[test]
801    fn test_blueprint_build_produces_valid_sql() {
802        let mut bp = Blueprint::new();
803        bp.id();
804        bp.string("name").not_null();
805        bp.integer("age");
806        let sql = bp.build().expect("build should succeed for valid columns");
807        assert!(sql.contains("id INTEGER PRIMARY KEY"));
808        assert!(sql.contains("name TEXT NOT NULL"));
809        assert!(sql.contains("age INTEGER"));
810    }
811
812    #[test]
813    fn test_column_default_to_sql_escaping() {
814        let default_text = ColumnDefault::Text("O'Reilly".to_string());
815        assert_eq!(default_text.to_sql(), "'O''Reilly'");
816    }
817
818    #[test]
819    fn test_validate_identifier_multiple_dots() {
820        assert!(validate_identifier("table.column").is_ok()); // one dot
821        assert!(validate_identifier("schema.table.column").is_err()); // multiple dots
822    }
823
824    #[test]
825    fn test_column_default_sql_rendering() {
826        assert_eq!(
827            ColumnDefault::CurrentTimestamp.to_sql(),
828            "CURRENT_TIMESTAMP"
829        );
830        assert_eq!(ColumnDefault::Null.to_sql(), "NULL");
831        assert_eq!(ColumnDefault::Integer(42).to_sql(), "42");
832        assert_eq!(ColumnDefault::Float(1.23).to_sql(), "1.23");
833        assert_eq!(ColumnDefault::Text("hello".to_string()).to_sql(), "'hello'");
834        // SQL injection via embedded quote must be escaped
835        assert_eq!(ColumnDefault::Text("it's".to_string()).to_sql(), "'it''s'");
836    }
837
838    #[test]
839    fn test_join_clause_on_eq_binds_value() {
840        let mut jc = JoinClause::new("orders");
841        jc.on_eq("orders.user_id", 42i32);
842        assert_eq!(jc.to_sql(), "orders.user_id = ?");
843        assert_eq!(jc.bindings.len(), 1);
844    }
845
846    #[test]
847    fn test_join_clause_multiple_conditions() {
848        let mut jc = JoinClause::new("posts");
849        jc.on("posts.user_id", "=", "users.id");
850        jc.on("posts.status", ">", "users.min_status");
851        assert_eq!(
852            jc.to_sql(),
853            "posts.user_id = users.id AND posts.status > users.min_status"
854        );
855    }
856
857    #[test]
858    fn test_column_builder_methods() {
859        let mut col = Column::new("age", "INTEGER");
860        assert_eq!(col.name, "age");
861        assert_eq!(col.col_type, "INTEGER");
862        assert!(col.is_nullable); // default is true
863        assert!(!col.is_primary_key);
864        assert!(!col.is_auto_increment);
865        assert_eq!(col.default_value, None);
866
867        col.not_null();
868        assert!(!col.is_nullable);
869
870        col.nullable();
871        assert!(col.is_nullable);
872
873        col.primary();
874        assert!(col.is_primary_key);
875
876        col.default(ColumnDefault::Integer(18));
877        assert_eq!(col.default_value, Some(ColumnDefault::Integer(18)));
878    }
879
880    #[tokio::test]
881    async fn test_db_migration_error_state_invalid_blueprint() {
882        let result = Schema::create("invalid; DROP TABLE users", |bp| {
883            bp.id();
884        })
885        .await;
886
887        assert!(result.is_err());
888    }
889
890    #[tokio::test]
891    async fn test_drop_if_exists_invalid_table() {
892        let result = Schema::drop_if_exists("invalid; name").await;
893        assert!(result.is_err());
894        assert!(matches!(result, Err(crate::Error::Internal(_))));
895    }
896}