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