1use crate::Error;
2
3const ALLOWED_OPERATORS: &[&str] = &["=", "!=", "<>", "<", ">", "<=", ">="];
5
6pub 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 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
53pub 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#[derive(Debug, Clone, PartialEq)]
69pub enum ColumnDefault {
70 CurrentTimestamp,
72 Null,
74 Integer(i64),
76 Float(f64),
78 Text(String),
81}
82
83impl ColumnDefault {
84 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 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 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 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 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 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 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 assert!(validate_table_name("users.id").is_err());
807 assert!(validate_table_name("").is_err()); }
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()); assert!(validate_identifier("DROP TABLE users").is_err());
818 assert!(validate_identifier("id; DROP TABLE users--").is_err());
819 assert!(validate_identifier(&"a".repeat(64)).is_ok());
821 assert!(validate_identifier(&"a".repeat(65)).is_err());
822 assert!(validate_identifier(".").is_err()); assert!(validate_identifier(".users").is_err()); assert!(validate_identifier("users.").is_err()); assert!(validate_identifier("user name").is_err()); assert!(validate_identifier("admin'--").is_err()); assert!(validate_identifier("users()").is_err()); assert!(validate_identifier("a*b").is_err()); 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()); assert!(validate_identifier("schema.table.column").is_err()); }
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 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); 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 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 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 let result = run_artisan(vec![], vec![]).await;
1041 assert!(result.is_ok());
1042 }
1043}