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 enum_col(&mut self, name: &str, variants: Vec<&str>) -> &mut Column {
206        // Enforce enum values using a CHECK constraint for safe cross-DB compatibility
207        let check_clause = variants
208            .iter()
209            .map(|v| format!("'{}'", v.replace('\'', "''")))
210            .collect::<Vec<_>>()
211            .join(", ");
212        let col_type = format!("TEXT CHECK({} IN ({}))", name, check_clause);
213        self.add_column(name, &col_type)
214    }
215
216    pub fn timestamps(&mut self) {
217        let mut created = Column::new("created_at", "TEXT");
218        created.default(ColumnDefault::CurrentTimestamp);
219        self.columns.push(created);
220
221        let mut updated = Column::new("updated_at", "TEXT");
222        updated.default(ColumnDefault::CurrentTimestamp);
223        self.columns.push(updated);
224    }
225
226    pub fn soft_deletes(&mut self) {
227        let col = Column::new("deleted_at", "TEXT");
228        self.columns.push(col);
229        self.columns
230            .last_mut()
231            .expect("BUG: columns is empty after push")
232            .nullable();
233    }
234
235    #[cfg_attr(test, mutants::skip)]
236    pub fn build(&self) -> Result<String, Error> {
237        let driver = crate::DB_DRIVER
238            .get()
239            .map(|s| s.as_str())
240            .unwrap_or("sqlite");
241        let mut defs = vec![];
242        for col in &self.columns {
243            // Defensive re-validation: column names must always be safe
244            // identifiers regardless of how the Column was constructed.
245            validate_identifier(&col.name)?;
246
247            let mut col_type_str = col.col_type.clone();
248            if driver == "postgres" && col.is_auto_increment {
249                if col.col_type == "INTEGER" || col.col_type == "INT" {
250                    col_type_str = "SERIAL".to_string();
251                } else if col.col_type == "BIGINT" {
252                    col_type_str = "BIGSERIAL".to_string();
253                }
254            }
255
256            let mut def = format!("{} {}", col.name, col_type_str);
257            if col.is_primary_key {
258                def.push_str(" PRIMARY KEY");
259            }
260            if col.is_auto_increment {
261                if driver == "sqlite" {
262                    def.push_str(" AUTOINCREMENT");
263                } else if driver == "mysql" {
264                    def.push_str(" AUTO_INCREMENT");
265                }
266            }
267            if !col.is_nullable && !col.is_primary_key {
268                def.push_str(" NOT NULL");
269            }
270            if let Some(default) = &col.default_value {
271                use std::fmt::Write;
272                write!(def, " DEFAULT {}", default.to_sql()).unwrap();
273            }
274            defs.push(def);
275        }
276        Ok(defs.join(",\n    "))
277    }
278}
279
280pub struct Schema;
281
282impl Schema {
283    pub async fn create<F>(table_name: &str, callback: F) -> Result<(), Error>
284    where
285        F: FnOnce(&mut Blueprint),
286    {
287        validate_table_name(table_name)?;
288
289        let mut blueprint = Blueprint::new();
290        callback(&mut blueprint);
291
292        // build() now returns Result so any column-name or default issues
293        // surface as errors rather than producing malformed SQL.
294        let columns_sql = blueprint.build()?;
295
296        let driver = crate::Orm::driver();
297        let escaped_table = match driver {
298            "mysql" => format!("`{}`", table_name),
299            _ => format!("\"{}\"", table_name),
300        };
301
302        let sql = format!(
303            "CREATE TABLE IF NOT EXISTS {} (\n    {}\n);",
304            escaped_table, columns_sql
305        );
306
307        let mut query_builder = sqlx::query_builder::QueryBuilder::new("");
308        query_builder.push(&sql);
309        let query = query_builder.build();
310        crate::execute_query!(query, execute, pool)?;
311
312        Ok(())
313    }
314
315    #[mutants::skip]
316    pub async fn drop_if_exists(table_name: &str) -> Result<(), Error> {
317        validate_table_name(table_name)?;
318        let driver = crate::Orm::driver();
319        let escaped_table = match driver {
320            "mysql" => format!("`{}`", table_name),
321            _ => format!("\"{}\"", table_name),
322        };
323
324        let sql = format!("DROP TABLE IF EXISTS {};", escaped_table);
325        let mut query_builder = sqlx::query_builder::QueryBuilder::new("");
326        query_builder.push(&sql);
327        let query = query_builder.build();
328        crate::execute_query!(query, execute, pool)?;
329        Ok(())
330    }
331}
332
333#[async_trait::async_trait]
334pub trait Migration: Send + Sync {
335    fn name(&self) -> &'static str;
336    async fn up(&self) -> Result<(), Error>;
337    async fn down(&self) -> Result<(), Error>;
338}
339
340#[cfg_attr(test, mutants::skip)]
341pub async fn run_artisan_with_args(
342    args: &[String],
343    migrations: Vec<Box<dyn Migration>>,
344    seeders: Vec<Box<dyn crate::Seeder>>,
345) -> Result<(), Error> {
346    if args.len() < 2 {
347        println!("Rullst ORM Artisan CLI");
348        println!("Usage:");
349        println!("  make:migration <name>   Generate a new migration");
350        println!("  migrate                  Run all pending migrations");
351        println!("  migrate:rollback         Rollback the last batch of migrations");
352        println!("  status                   Show migrations status");
353        println!("  db:seed                  Populate the database with seeders");
354        println!(
355            "  sail:install             Generate a default docker-compose.yml (Laravel Sail style)"
356        );
357        return Ok(());
358    }
359
360    let command = &args[1];
361    match command.as_str() {
362        "make:migration" => {
363            if args.len() < 3 {
364                println!("Error: migration name is required.");
365                return Ok(());
366            }
367            let name = &args[2];
368            create_migration_files(name)?;
369        }
370        "migrate" | "db:migrate" => {
371            run_migrations(migrations).await?;
372        }
373        "migrate:rollback" | "db:rollback" => {
374            rollback_migrations(migrations).await?;
375        }
376        "status" | "db:status" => {
377            status_migrations(migrations).await?;
378        }
379        "db:seed" => {
380            println!("Seeding database...");
381            crate::Orm::seed(seeders).await?;
382            println!("Database seeded successfully!");
383        }
384        "sail:install" => {
385            println!("Generating docker-compose.yml...");
386            let content = r#"version: '3'
387services:
388  postgres:
389    image: postgres:15
390    ports:
391      - "5432:5432"
392    environment:
393      POSTGRES_DB: rullst
394      POSTGRES_USER: root
395      POSTGRES_PASSWORD: password
396    volumes:
397      - sail-postgres:/var/lib/postgresql/data
398  redis:
399    image: redis:alpine
400    ports:
401      - "6379:6379"
402    volumes:
403      - sail-redis:/data
404  meilisearch:
405    image: getmeili/meilisearch:latest
406    ports:
407      - "7700:7700"
408    environment:
409      MEILI_MASTER_KEY: sail
410    volumes:
411      - sail-meilisearch:/meili_data
412  pgadmin:
413    image: dpage/pgadmin4
414    ports:
415      - "5050:80"
416    environment:
417      PGADMIN_DEFAULT_EMAIL: admin@rullst.com
418      PGADMIN_DEFAULT_PASSWORD: password
419
420volumes:
421  sail-postgres:
422    driver: local
423  sail-redis:
424    driver: local
425  sail-meilisearch:
426    driver: local
427"#;
428            std::fs::write("docker-compose.yml", content).map_err(|e| {
429                crate::Error::Internal(format!("Failed to write docker-compose.yml: {}", e))
430            })?;
431            println!(
432                "docker-compose.yml created successfully! Run `docker compose up -d` to start."
433            );
434        }
435        _ => {
436            println!("Unknown command: {}", command);
437        }
438    }
439    Ok(())
440}
441
442#[cfg_attr(test, mutants::skip)]
443pub async fn run_artisan(
444    migrations: Vec<Box<dyn Migration>>,
445    seeders: Vec<Box<dyn crate::Seeder>>,
446) -> Result<(), Error> {
447    let args: Vec<String> = std::env::args().collect();
448    run_artisan_with_args(&args, migrations, seeders).await
449}
450
451#[mutants::skip]
452async fn migrations_table_exists(pool: &crate::RullstPool, driver: &str) -> Result<bool, Error> {
453    match driver {
454        "postgres" | "mysql" => {
455            let query_str =
456                "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'migrations'";
457            let row: (i64,) = sqlx::query_as(query_str).fetch_one(pool).await?;
458            Ok(row.0 > 0)
459        }
460        _ => {
461            let query_str =
462                "SELECT COUNT(*) FROM sqlite_schema WHERE type='table' AND name='migrations'";
463            let row: (i64,) = sqlx::query_as(query_str).fetch_one(pool).await?;
464            Ok(row.0 > 0)
465        }
466    }
467}
468
469#[cfg_attr(test, mutants::skip)]
470async fn status_migrations(migrations: Vec<Box<dyn Migration>>) -> Result<(), Error> {
471    let pool = crate::Orm::pool();
472    let driver = crate::Orm::driver();
473
474    let table_exists = migrations_table_exists(pool, driver).await?;
475
476    let executed_set = if table_exists {
477        let executed: Vec<(String,)> = sqlx::query_as("SELECT migration FROM migrations")
478            .fetch_all(pool)
479            .await?;
480        executed
481            .into_iter()
482            .map(|(m,)| m)
483            .collect::<std::collections::HashSet<String>>()
484    } else {
485        std::collections::HashSet::new()
486    };
487
488    let name_header = "Migration Name";
489    let status_header = "Status";
490    println!("{name_header:<40} | {status_header}");
491    println!("{}", "-".repeat(55));
492    for m in migrations {
493        let name = m.name();
494        let status = if executed_set.contains(name) {
495            "Applied"
496        } else {
497            "Pending"
498        };
499        println!("{:<40} | {}", name, status);
500    }
501
502    Ok(())
503}
504
505#[cfg_attr(test, mutants::skip)]
506fn create_migration_files(name: &str) -> Result<(), Error> {
507    validate_table_name(name)?;
508    use std::fs;
509
510    let now = std::time::SystemTime::now()
511        .duration_since(std::time::UNIX_EPOCH)
512        .expect("System time went backwards")
513        .as_secs()
514        .to_string();
515    let sanitized_name = name.replace(['/', '\\'], "");
516    let snake_name = sanitized_name.to_lowercase().replace("-", "_");
517    let file_name = format!("m{}_{}", now, snake_name);
518
519    fs::create_dir_all("src/migrations")
520        .map_err(|e| Error::Internal(format!("Failed to create migrations directory: {}", e)))?;
521
522    let new_file_path = format!("src/migrations/{}.rs", file_name);
523    let template = include_str!("migration_template.rs.txt");
524    let migration_code = template
525        .replace("{timestamp}", &now)
526        .replace("{name}", &snake_name);
527
528    fs::write(&new_file_path, migration_code)
529        .map_err(|e| Error::Internal(format!("Failed to write migration file: {}", e)))?;
530    println!("Created migration file: {}", new_file_path);
531
532    regenerate_migrations_mod()?;
533
534    Ok(())
535}
536
537#[cfg_attr(test, mutants::skip)]
538fn regenerate_migrations_mod() -> Result<(), Error> {
539    use std::fs;
540    let paths = fs::read_dir("src/migrations")
541        .map_err(|e| Error::Internal(format!("Failed to read migrations dir: {}", e)))?;
542
543    let mut modules = vec![];
544    for path in paths {
545        let path = path.map_err(|e| Error::Internal(e.to_string()))?.path();
546        if let Some(ext) = path.extension()
547            && ext == "rs"
548            && let Some(stem) = path.file_stem()
549        {
550            let stem_str = stem.to_string_lossy().to_string();
551            if stem_str != "mod" && stem_str.starts_with('m') {
552                modules.push(stem_str);
553            }
554        }
555    }
556    modules.sort();
557
558    use std::fmt::Write;
559    let mut mod_content = String::new();
560    mod_content.push_str("// Generated by Rullst ORM Artisan. Do not edit manually.\n\n");
561    for m in &modules {
562        writeln!(mod_content, "pub mod {};", m).unwrap();
563    }
564    mod_content
565        .push_str("\npub fn get_migrations() -> Vec<Box<dyn rullst_orm::schema::Migration>> {\n");
566    mod_content.push_str("    vec![\n");
567    for m in &modules {
568        writeln!(mod_content, "        Box::new({}::MigrationImpl),", m).unwrap();
569    }
570    mod_content.push_str("    ]\n");
571    mod_content.push_str("}\n");
572
573    fs::write("src/migrations/mod.rs", mod_content)
574        .map_err(|e| Error::Internal(format!("Failed to write mod.rs: {}", e)))?;
575    println!("Regenerated src/migrations/mod.rs");
576
577    Ok(())
578}
579
580#[cfg_attr(test, mutants::skip)]
581async fn run_migrations(migrations: Vec<Box<dyn Migration>>) -> Result<(), Error> {
582    let pool = crate::Orm::pool();
583    let driver = crate::Orm::driver();
584
585    let query_str = match driver {
586        "postgres" => {
587            "CREATE TABLE IF NOT EXISTS migrations (
588                id SERIAL PRIMARY KEY,
589                migration VARCHAR(255) NOT NULL,
590                batch INTEGER NOT NULL
591            )"
592        }
593        "mysql" => {
594            "CREATE TABLE IF NOT EXISTS migrations (
595                id INT AUTO_INCREMENT PRIMARY KEY,
596                migration VARCHAR(255) NOT NULL,
597                batch INT NOT NULL
598            )"
599        }
600        _ => {
601            "CREATE TABLE IF NOT EXISTS migrations (
602                id INTEGER PRIMARY KEY AUTOINCREMENT,
603                migration TEXT NOT NULL,
604                batch INTEGER NOT NULL
605            )"
606        }
607    };
608
609    sqlx::query(query_str).execute(pool).await?;
610
611    let executed: Vec<(String,)> = sqlx::query_as("SELECT migration FROM migrations")
612        .fetch_all(pool)
613        .await?;
614    let executed_set: std::collections::HashSet<String> =
615        executed.into_iter().map(|(m,)| m).collect();
616
617    let batch_row: (Option<i32>,) = sqlx::query_as("SELECT MAX(batch) FROM migrations")
618        .fetch_one(pool)
619        .await?;
620    let next_batch = batch_row.0.unwrap_or(0) + 1;
621
622    let mut count = 0;
623    let mut successful_migrations = vec![];
624    for m in migrations {
625        let name = m.name();
626        if !executed_set.contains(name) {
627            println!("Migrating: {}", name);
628            m.up().await?;
629            successful_migrations.push(name);
630            println!("Migrated:  {}", name);
631            count += 1;
632        }
633    }
634
635    if count > 0 {
636        let mut query_builder =
637            sqlx::query_builder::QueryBuilder::new("INSERT INTO migrations (migration, batch) ");
638        query_builder.push_values(successful_migrations, |mut b, name| {
639            b.push_bind(name).push_bind(next_batch);
640        });
641        query_builder.build().execute(pool).await?;
642    } else {
643        println!("Nothing to migrate.");
644    }
645
646    Ok(())
647}
648
649#[cfg_attr(test, mutants::skip)]
650async fn rollback_migrations(migrations: Vec<Box<dyn Migration>>) -> Result<(), Error> {
651    let pool = crate::Orm::pool();
652    let driver = crate::Orm::driver();
653
654    let table_exists = migrations_table_exists(pool, driver).await?;
655
656    if !table_exists {
657        println!("Nothing to rollback.");
658        return Ok(());
659    }
660
661    let batch_row: (Option<i32>,) = sqlx::query_as("SELECT MAX(batch) FROM migrations")
662        .fetch_one(pool)
663        .await?;
664
665    let last_batch = match batch_row.0 {
666        Some(b) if b > 0 => b,
667        _ => {
668            println!("Nothing to rollback.");
669            return Ok(());
670        }
671    };
672
673    let to_rollback: Vec<(String,)> =
674        sqlx::query_as("SELECT migration FROM migrations WHERE batch = ? ORDER BY id DESC")
675            .bind(last_batch)
676            .fetch_all(pool)
677            .await?;
678
679    let mut rollback_map = std::collections::HashMap::with_capacity(migrations.len());
680    for m in migrations {
681        rollback_map.insert(m.name().to_string(), m);
682    }
683
684    let mut rolled_back = Vec::with_capacity(to_rollback.len());
685    for (name,) in to_rollback {
686        if let Some(m) = rollback_map.get(&name) {
687            println!("Rolling back: {}", name);
688            m.down().await?;
689            println!("Rolled back:  {}", name);
690            rolled_back.push(name);
691        } else {
692            println!(
693                "Warning: migration {} found in database but not in compiled binary.",
694                name
695            );
696        }
697    }
698
699    if !rolled_back.is_empty() {
700        let mut query_builder =
701            sqlx::query_builder::QueryBuilder::new("DELETE FROM migrations WHERE migration IN (");
702        let mut separated = query_builder.separated(", ");
703        for name in rolled_back {
704            separated.push_bind(name);
705        }
706        separated.push_unseparated(")");
707        query_builder.build().execute(pool).await?;
708    }
709
710    Ok(())
711}
712
713pub struct JoinClause {
714    pub table: String,
715    pub conditions: Vec<String>,
716    pub bindings: Vec<crate::RullstValue>,
717    pub errors: Vec<crate::Error>,
718}
719
720impl JoinClause {
721    pub fn new(table: &str) -> Self {
722        Self {
723            table: table.to_string(),
724            conditions: vec![],
725            bindings: vec![],
726            errors: vec![],
727        }
728    }
729
730    /// Adds a column-to-column JOIN condition.
731    ///
732    /// This prevents SQL injection — column names should always be hardcoded, never
733    /// derived from user input. Returns errors internally rather than panicking.
734    pub fn on(&mut self, first: &str, operator: &str, second: &str) -> &mut Self {
735        if let Err(e) = validate_identifier(first) {
736            self.errors.push(crate::Error::Validation(format!(
737                "JoinClause::on — invalid identifier for `first`: {:?}",
738                e
739            )));
740        }
741        if let Err(e) = validate_identifier(second) {
742            self.errors.push(crate::Error::Validation(format!(
743                "JoinClause::on — invalid identifier for `second`: {:?}",
744                e
745            )));
746        }
747        if !ALLOWED_OPERATORS.contains(&operator) {
748            self.errors.push(crate::Error::Validation(format!(
749                "JoinClause::on — invalid operator '{}'. Allowed: {:?}",
750                operator, ALLOWED_OPERATORS
751            )));
752        }
753        self.conditions
754            .push(format!("{} {} {}", first, operator, second));
755        self
756    }
757
758    pub fn on_eq<T: Into<crate::RullstValue>>(&mut self, column: &str, value: T) -> &mut Self {
759        if let Err(e) = validate_identifier(column) {
760            self.errors.push(crate::Error::Validation(format!(
761                "JoinClause::on_eq — invalid identifier for `column`: {:?}",
762                e
763            )));
764        }
765        self.conditions.push(format!("{} = ?", column));
766        self.bindings.push(value.into());
767        self
768    }
769
770    pub fn to_sql(&self) -> String {
771        self.conditions.join(" AND ")
772    }
773}
774
775pub trait SubqueryBuilder {
776    fn to_sql(&self) -> String;
777    fn bindings(&self) -> &Vec<crate::RullstValue>;
778}
779
780pub static QUERY_LOGGING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
781pub static MAX_QUERY_LIMIT: std::sync::atomic::AtomicUsize =
782    std::sync::atomic::AtomicUsize::new(1000);
783pub static QUERY_TIMEOUT_SECS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(30);
784
785pub fn enable_query_log() {
786    QUERY_LOGGING.store(true, std::sync::atomic::Ordering::SeqCst);
787}
788
789pub fn disable_query_log() {
790    QUERY_LOGGING.store(false, std::sync::atomic::Ordering::SeqCst);
791}
792
793pub fn is_query_log_enabled() -> bool {
794    QUERY_LOGGING.load(std::sync::atomic::Ordering::SeqCst)
795}
796
797pub fn set_max_query_limit(limit: usize) {
798    MAX_QUERY_LIMIT.store(limit, std::sync::atomic::Ordering::SeqCst);
799}
800
801pub fn get_max_query_limit() -> Option<usize> {
802    let limit = MAX_QUERY_LIMIT.load(std::sync::atomic::Ordering::SeqCst);
803    if limit == 0 { None } else { Some(limit) }
804}
805
806pub fn set_query_timeout(secs: u64) {
807    QUERY_TIMEOUT_SECS.store(secs, std::sync::atomic::Ordering::SeqCst);
808}
809
810pub fn get_query_timeout() -> Option<std::time::Duration> {
811    let secs = QUERY_TIMEOUT_SECS.load(std::sync::atomic::Ordering::SeqCst);
812    if secs == 0 {
813        None
814    } else {
815        Some(std::time::Duration::from_secs(secs))
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    struct MockSubquery {
824        sql: String,
825        bindings: Vec<crate::RullstValue>,
826    }
827
828    impl SubqueryBuilder for MockSubquery {
829        fn to_sql(&self) -> String {
830            self.sql.clone()
831        }
832        fn bindings(&self) -> &Vec<crate::RullstValue> {
833            &self.bindings
834        }
835    }
836
837    #[test]
838    fn test_subquery_builder_trait() {
839        let sq = MockSubquery {
840            sql: "SELECT * FROM users WHERE id = ?".to_string(),
841            bindings: vec![42.into()],
842        };
843        assert_eq!(sq.to_sql(), "SELECT * FROM users WHERE id = ?");
844        assert_eq!(sq.bindings().len(), 1);
845    }
846
847    #[test]
848    fn test_enable_disable_query_log() {
849        disable_query_log();
850        assert!(!is_query_log_enabled());
851        enable_query_log();
852        assert!(is_query_log_enabled());
853        disable_query_log();
854        assert!(!is_query_log_enabled());
855    }
856
857    #[test]
858    fn test_join_clause() {
859        let mut jc = JoinClause::new("users");
860        jc.on("users.id", "=", "posts.user_id");
861        assert_eq!(jc.to_sql(), "users.id = posts.user_id");
862    }
863
864    #[test]
865    fn test_validate_table_name() {
866        assert!(validate_table_name("users").is_ok());
867        assert!(validate_table_name("user_posts").is_ok());
868        assert!(validate_table_name("DROP TABLE users").is_err());
869        assert!(validate_table_name("../../../etc/shadow").is_err());
870        // dots not allowed in table names
871        assert!(validate_table_name("users.id").is_err());
872        assert!(validate_table_name("").is_err()); // Empty table name
873    }
874
875    #[test]
876    fn test_validate_identifier() {
877        assert!(validate_identifier("users").is_ok());
878        assert!(validate_identifier("users.id").is_ok());
879        assert!(validate_identifier("user_posts").is_ok());
880        assert!(validate_identifier("").is_err());
881        assert!(validate_identifier("users.posts.id").is_err()); // two dots
882        assert!(validate_identifier("DROP TABLE users").is_err());
883        assert!(validate_identifier("id; DROP TABLE users--").is_err());
884        // Length check
885        assert!(validate_identifier(&"a".repeat(64)).is_ok());
886        assert!(validate_identifier(&"a".repeat(65)).is_err());
887        // Leading/trailing dot edge cases — all now rejected
888        assert!(validate_identifier(".").is_err()); // bare dot: starts AND ends with dot
889        assert!(validate_identifier(".users").is_err()); // leading dot
890        assert!(validate_identifier("users.").is_err()); // trailing dot
891        assert!(validate_identifier("user name").is_err()); // Spaces not allowed
892        assert!(validate_identifier("admin'--").is_err()); // Quotes not allowed
893        assert!(validate_identifier("users()").is_err()); // Parentheses not allowed
894        assert!(validate_identifier("a*b").is_err()); // Asterisk not allowed
895
896        // Extensive error tests
897        assert!(validate_identifier("SELECT * FROM users").is_err());
898        assert!(validate_identifier("users\nWHERE").is_err());
899        assert!(validate_identifier("users\t").is_err());
900        assert!(validate_identifier("\\").is_err());
901    }
902
903    #[test]
904    fn test_join_clause_on_invalid_operator() {
905        let mut jc = JoinClause::new("posts");
906        jc.on("posts.user_id", "OR 1=1 --", "users.id");
907        assert!(!jc.errors.is_empty());
908        assert!(jc.errors[0].to_string().contains("invalid operator"));
909    }
910
911    #[test]
912    fn test_join_clause_on_invalid_column() {
913        let mut jc = JoinClause::new("posts");
914        jc.on("users.id; DROP TABLE users--", "=", "posts.user_id");
915        assert!(!jc.errors.is_empty());
916        assert!(jc.errors[0].to_string().contains("invalid identifier"));
917    }
918
919    #[test]
920    fn test_timestamps_adds_columns() {
921        let mut bp = Blueprint::new();
922        bp.timestamps();
923        assert_eq!(bp.columns.len(), 2);
924        assert_eq!(bp.columns[0].name, "created_at");
925        assert_eq!(bp.columns[0].col_type, "TEXT");
926        assert!(bp.columns[0].is_nullable);
927        assert_eq!(
928            bp.columns[0].default_value,
929            Some(ColumnDefault::CurrentTimestamp)
930        );
931
932        assert_eq!(bp.columns[1].name, "updated_at");
933        assert_eq!(bp.columns[1].col_type, "TEXT");
934        assert!(bp.columns[1].is_nullable);
935        assert_eq!(
936            bp.columns[1].default_value,
937            Some(ColumnDefault::CurrentTimestamp)
938        );
939    }
940
941    #[test]
942    fn test_soft_deletes_adds_nullable_column() {
943        let mut bp = Blueprint::new();
944        bp.soft_deletes();
945        assert_eq!(bp.columns.len(), 1);
946        assert_eq!(bp.columns[0].name, "deleted_at");
947        assert!(bp.columns[0].is_nullable);
948    }
949
950    #[test]
951    fn test_blueprint_build_produces_valid_sql() {
952        let mut bp = Blueprint::new();
953        bp.id();
954        bp.string("name").not_null();
955        bp.integer("age");
956        let sql = bp.build().expect("build should succeed for valid columns");
957        assert!(sql.contains("id INTEGER PRIMARY KEY"));
958        assert!(sql.contains("name TEXT NOT NULL"));
959        assert!(sql.contains("age INTEGER"));
960    }
961
962    #[test]
963    fn test_column_default_to_sql_escaping() {
964        let default_text = ColumnDefault::Text("O'Reilly".to_string());
965        assert_eq!(default_text.to_sql(), "'O''Reilly'");
966    }
967
968    #[test]
969    fn test_validate_identifier_multiple_dots() {
970        assert!(validate_identifier("table.column").is_ok()); // one dot
971        assert!(validate_identifier("schema.table.column").is_err()); // multiple dots
972    }
973
974    #[test]
975    fn test_column_default_sql_rendering() {
976        assert_eq!(
977            ColumnDefault::CurrentTimestamp.to_sql(),
978            "CURRENT_TIMESTAMP"
979        );
980        assert_eq!(ColumnDefault::Null.to_sql(), "NULL");
981        assert_eq!(ColumnDefault::Integer(42).to_sql(), "42");
982        assert_eq!(ColumnDefault::Float(1.23).to_sql(), "1.23");
983        assert_eq!(ColumnDefault::Text("hello".to_string()).to_sql(), "'hello'");
984        // SQL injection via embedded quote must be escaped
985        assert_eq!(ColumnDefault::Text("it's".to_string()).to_sql(), "'it''s'");
986    }
987
988    #[test]
989    fn test_join_clause_on_eq_binds_value() {
990        let mut jc = JoinClause::new("orders");
991        jc.on_eq("orders.user_id", 42i32);
992        assert_eq!(jc.to_sql(), "orders.user_id = ?");
993        assert_eq!(jc.bindings.len(), 1);
994    }
995
996    #[test]
997    fn test_join_clause_multiple_conditions() {
998        let mut jc = JoinClause::new("posts");
999        jc.on("posts.user_id", "=", "users.id");
1000        jc.on("posts.status", ">", "users.min_status");
1001        assert_eq!(
1002            jc.to_sql(),
1003            "posts.user_id = users.id AND posts.status > users.min_status"
1004        );
1005    }
1006
1007    #[test]
1008    fn test_column_builder_methods() {
1009        let mut col = Column::new("age", "INTEGER");
1010        assert_eq!(col.name, "age");
1011        assert_eq!(col.col_type, "INTEGER");
1012        assert!(col.is_nullable); // default is true
1013        assert!(!col.is_primary_key);
1014        assert!(!col.is_auto_increment);
1015        assert_eq!(col.default_value, None);
1016
1017        col.not_null();
1018        assert!(!col.is_nullable);
1019
1020        col.nullable();
1021        assert!(col.is_nullable);
1022
1023        col.primary();
1024        assert!(col.is_primary_key);
1025
1026        col.default(ColumnDefault::Integer(18));
1027        assert_eq!(col.default_value, Some(ColumnDefault::Integer(18)));
1028    }
1029
1030    #[test]
1031    fn test_column_nullable_and_not_null_flips() {
1032        let mut col = Column::new("status", "TEXT");
1033        assert!(col.is_nullable);
1034        col.not_null();
1035        assert!(!col.is_nullable);
1036        col.nullable();
1037        assert!(col.is_nullable);
1038    }
1039
1040    #[test]
1041    fn test_blueprint_float_and_boolean_columns() {
1042        let mut bp = Blueprint::new();
1043        let col_float = bp.float("price");
1044        assert_eq!(col_float.name, "price");
1045        assert_eq!(col_float.col_type, "REAL");
1046        assert!(col_float.is_nullable);
1047
1048        let col_bool = bp.boolean("is_active");
1049        assert_eq!(col_bool.name, "is_active");
1050        assert_eq!(col_bool.col_type, "INTEGER");
1051        assert!(col_bool.is_nullable);
1052    }
1053
1054    #[test]
1055    fn test_blueprint_enum_column() {
1056        let mut bp = Blueprint::new();
1057        let col = bp.enum_col("status", vec!["Active", "Pending", "Canceled"]);
1058        assert_eq!(col.name, "status");
1059        assert_eq!(
1060            col.col_type,
1061            "TEXT CHECK(status IN ('Active', 'Pending', 'Canceled'))"
1062        );
1063        assert!(col.is_nullable);
1064    }
1065
1066    #[test]
1067    fn test_blueprint_boolean_column() {
1068        let mut bp = Blueprint::new();
1069        let col = bp.boolean("verified");
1070        assert_eq!(col.name, "verified");
1071        assert_eq!(col.col_type, "INTEGER");
1072        assert!(col.is_nullable);
1073        assert!(!col.is_primary_key);
1074        assert!(!col.is_auto_increment);
1075        assert_eq!(col.default_value, None);
1076    }
1077
1078    #[tokio::test]
1079    async fn test_db_migration_error_state_invalid_blueprint() {
1080        let result = Schema::create("invalid; DROP TABLE users", |bp| {
1081            bp.id();
1082        })
1083        .await;
1084
1085        assert!(result.is_err());
1086    }
1087
1088    #[tokio::test]
1089    async fn test_drop_if_exists_invalid_table() {
1090        let result = Schema::drop_if_exists("invalid; name").await;
1091        assert!(result.is_err());
1092        assert!(matches!(result, Err(crate::Error::Internal(_))));
1093    }
1094
1095    #[test]
1096    fn test_max_query_limit_and_timeout_globals() {
1097        // Test limit
1098        set_max_query_limit(50);
1099        assert_eq!(get_max_query_limit(), Some(50));
1100        set_max_query_limit(0);
1101        assert_eq!(get_max_query_limit(), None);
1102
1103        // Test timeout
1104        set_query_timeout(10);
1105        assert_eq!(
1106            get_query_timeout(),
1107            Some(std::time::Duration::from_secs(10))
1108        );
1109        set_query_timeout(0);
1110        assert_eq!(get_query_timeout(), None);
1111    }
1112
1113    #[tokio::test]
1114    async fn test_run_artisan_entrypoint() {
1115        // Calling run_artisan with empty lists. It parses std::env::args() and prints help
1116        // Note: we can't easily mock std::env::args here, so we just run it and let it fall through.
1117        let result = run_artisan(vec![], vec![]).await;
1118        assert!(result.is_ok());
1119    }
1120
1121    #[tokio::test]
1122    async fn test_sail_install() {
1123        let args = vec!["artisan".to_string(), "sail:install".to_string()];
1124        let result = run_artisan_with_args(&args, vec![], vec![]).await;
1125        assert!(result.is_ok());
1126
1127        let content = std::fs::read_to_string("docker-compose.yml").unwrap();
1128        assert!(content.contains("postgres:15"));
1129        assert!(content.contains("redis:alpine"));
1130
1131        // Cleanup
1132        std::fs::remove_file("docker-compose.yml").unwrap();
1133    }
1134}