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