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 enum_col(&mut self, name: &str, variants: Vec<&str>) -> &mut Column {
206 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 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 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 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 assert!(validate_table_name("users.id").is_err());
872 assert!(validate_table_name("").is_err()); }
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()); assert!(validate_identifier("DROP TABLE users").is_err());
883 assert!(validate_identifier("id; DROP TABLE users--").is_err());
884 assert!(validate_identifier(&"a".repeat(64)).is_ok());
886 assert!(validate_identifier(&"a".repeat(65)).is_err());
887 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());
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()); assert!(validate_identifier("schema.table.column").is_err()); }
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 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); 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 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 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 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 std::fs::remove_file("docker-compose.yml").unwrap();
1133 }
1134}