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