use super::*;
struct MockSubquery {
sql: String,
bindings: Vec<crate::RullstValue>,
}
impl SubqueryBuilder for MockSubquery {
fn to_sql(&self) -> String {
self.sql.clone()
}
fn bindings(&self) -> &Vec<crate::RullstValue> {
&self.bindings
}
}
#[test]
fn test_subquery_builder_trait() {
let sq = MockSubquery {
sql: "SELECT * FROM users WHERE id = ?".to_string(),
bindings: vec![42.into()],
};
assert_eq!(sq.to_sql(), "SELECT * FROM users WHERE id = ?");
assert_eq!(sq.bindings().len(), 1);
}
#[test]
fn test_enable_disable_query_log() {
disable_query_log();
assert!(!is_query_log_enabled());
enable_query_log();
assert!(is_query_log_enabled());
disable_query_log();
assert!(!is_query_log_enabled());
}
#[test]
fn test_join_clause() {
let mut jc = JoinClause::new("users");
jc.on("users.id", "=", "posts.user_id");
assert_eq!(jc.to_sql(), "users.id = posts.user_id");
}
#[test]
fn schema_constructors_accept_owned_strings_and_reject_invalid_join_tables() {
let column = Column::new(String::from("display_name"), String::from("TEXT"));
assert_eq!(column.name, "display_name");
assert_eq!(column.col_type, "TEXT");
let valid = JoinClause::new(String::from("users"));
assert!(valid.errors.is_empty());
let invalid = JoinClause::new(String::from("users; DROP TABLE users"));
assert_eq!(invalid.errors.len(), 1);
}
#[test]
fn test_validate_table_name() {
assert!(validate_table_name("users").is_ok());
assert!(validate_table_name("user_posts").is_ok());
assert!(validate_table_name("DROP TABLE users").is_err());
assert!(validate_table_name("../../../etc/shadow").is_err());
assert!(validate_table_name("user-posts").is_err());
assert!(validate_table_name("users.id").is_err());
assert!(validate_table_name("").is_err()); }
#[test]
fn test_validate_identifier() {
assert!(validate_identifier("users").is_ok());
assert!(validate_identifier("users.id").is_ok());
assert!(validate_identifier("user_posts").is_ok());
assert!(validate_identifier("user-posts").is_err());
assert!(validate_identifier("users.post-id").is_err());
assert!(validate_identifier("").is_err());
assert!(validate_identifier("users.posts.id").is_err()); assert!(validate_identifier("DROP TABLE users").is_err());
assert!(validate_identifier("id; DROP TABLE users--").is_err());
assert!(validate_identifier(&"a".repeat(64)).is_ok());
assert!(validate_identifier(&"a".repeat(65)).is_err());
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());
assert!(validate_identifier("users\nWHERE").is_err());
assert!(validate_identifier("users\t").is_err());
assert!(validate_identifier("\\").is_err());
}
#[test]
fn test_join_clause_on_invalid_operator() {
let mut jc = JoinClause::new("posts");
jc.on("posts.user_id", "OR 1=1 --", "users.id");
assert!(!jc.errors.is_empty());
assert!(jc.errors[0].to_string().contains("invalid operator"));
}
#[test]
fn test_join_clause_on_invalid_column() {
let mut jc = JoinClause::new("posts");
jc.on("users.id; DROP TABLE users--", "=", "posts.user_id");
assert!(!jc.errors.is_empty());
assert!(jc.errors[0].to_string().contains("invalid identifier"));
}
#[test]
fn test_timestamps_adds_columns() {
let mut bp = Blueprint::new();
bp.timestamps();
assert_eq!(bp.columns.len(), 2);
assert_eq!(bp.columns[0].name, "created_at");
assert_eq!(bp.columns[0].col_type, "TEXT");
assert!(bp.columns[0].is_nullable);
assert_eq!(
bp.columns[0].default_value,
Some(ColumnDefault::CurrentTimestamp)
);
assert_eq!(bp.columns[1].name, "updated_at");
assert_eq!(bp.columns[1].col_type, "TEXT");
assert!(bp.columns[1].is_nullable);
assert_eq!(
bp.columns[1].default_value,
Some(ColumnDefault::CurrentTimestamp)
);
}
#[test]
fn timestamps_use_a_mysql_expression_default_and_keep_other_drivers() {
let mut bp = Blueprint::new();
bp.id();
bp.timestamps();
let mysql = bp.build_for_driver("mysql").expect("MySQL DDL");
assert!(
mysql.contains("created_at TEXT DEFAULT (CURRENT_TIMESTAMP)"),
"{mysql}"
);
assert!(
mysql.contains("updated_at TEXT DEFAULT (CURRENT_TIMESTAMP)"),
"{mysql}"
);
assert!(!mysql.contains("TEXT DEFAULT CURRENT_TIMESTAMP"), "{mysql}");
for driver in ["sqlite", "postgres"] {
let sql = bp.build_for_driver(driver).expect("DDL");
assert!(
sql.contains("created_at TEXT DEFAULT CURRENT_TIMESTAMP"),
"{driver}: {sql}"
);
assert!(
sql.contains("updated_at TEXT DEFAULT CURRENT_TIMESTAMP"),
"{driver}: {sql}"
);
}
}
#[test]
fn mysql_text_like_defaults_are_expressions_and_other_defaults_are_literals() {
let mut bp = Blueprint::new();
bp.string("status")
.not_null()
.default(ColumnDefault::Text("draft".to_string()));
bp.string("note").default(ColumnDefault::Null);
bp.integer("attempts")
.not_null()
.default(ColumnDefault::Integer(0));
bp.enum_col("kind", vec!["a", "b"])
.default(ColumnDefault::Text("a".to_string()));
bp.columns.push({
let mut payload = Column::new("payload", "json");
payload.default(ColumnDefault::Text("{}".to_string()));
payload
});
let mysql = bp.build_for_driver("mysql").expect("MySQL DDL");
assert!(
mysql.contains("status TEXT NOT NULL DEFAULT ('draft')"),
"{mysql}"
);
assert!(mysql.contains("note TEXT DEFAULT NULL"), "{mysql}");
assert!(
mysql.contains("attempts INTEGER NOT NULL DEFAULT 0"),
"{mysql}"
);
assert!(
mysql.contains("kind TEXT CHECK(kind IN ('a', 'b')) DEFAULT ('a')"),
"{mysql}"
);
assert!(mysql.contains("payload json DEFAULT ('{}')"), "{mysql}");
let sqlite = bp.build_for_driver("sqlite").expect("SQLite DDL");
assert!(
sqlite.contains("status TEXT NOT NULL DEFAULT 'draft'"),
"{sqlite}"
);
assert!(sqlite.contains("payload json DEFAULT '{}'"), "{sqlite}");
}
#[test]
fn ddl_text_literals_reject_backslashes_and_control_characters() {
for text in ["x\\", "a\\'b", "line\nfeed", "nul\0"] {
let mut defaults = Blueprint::new();
defaults
.string("status")
.default(ColumnDefault::Text(text.to_string()));
let mut variants = Blueprint::new();
variants.enum_col("kind", vec!["ok", text]);
for driver in ["mysql", "postgres", "sqlite"] {
for blueprint in [&defaults, &variants] {
assert!(
matches!(
blueprint.build_for_driver(driver),
Err(crate::Error::Validation(_))
),
"{driver}: {text:?}"
);
}
}
}
let mut blueprint = Blueprint::new();
blueprint
.string("note")
.default(ColumnDefault::Text("owner's não".to_string()));
blueprint.enum_col("kind", vec!["it's"]);
let mysql = blueprint
.build_for_driver("mysql")
.expect("quoted literals");
assert!(mysql.contains("DEFAULT ('owner''s não')"), "{mysql}");
assert!(mysql.contains("CHECK(kind IN ('it''s'))"), "{mysql}");
}
#[test]
fn mysql_native_enum_defaults_remain_literals() {
let mut blueprint = Blueprint::new();
blueprint
.native_enum::<AccountStatus>("status")
.default(ColumnDefault::Text("owners_active".to_string()));
let mysql = blueprint.build_for_driver("mysql").expect("MySQL enum DDL");
assert!(
mysql.contains("status ENUM('awaiting_review', 'owners_active') DEFAULT 'owners_active'"),
"{mysql}"
);
}
#[test]
fn test_soft_deletes_adds_nullable_column() {
let mut bp = Blueprint::new();
bp.soft_deletes();
assert_eq!(bp.columns.len(), 1);
assert_eq!(bp.columns[0].name, "deleted_at");
assert!(bp.columns[0].is_nullable);
}
#[test]
fn test_blueprint_build_produces_valid_sql() {
let mut bp = Blueprint::new();
bp.id();
bp.string("name").not_null();
bp.integer("age");
bp.big_integer("epoch_seconds");
let sql = bp
.build_for_driver("sqlite")
.expect("build should succeed for valid columns");
assert!(sql.contains("id INTEGER PRIMARY KEY"));
assert!(sql.contains("name TEXT NOT NULL"));
assert!(sql.contains("age INTEGER"));
assert!(sql.contains("epoch_seconds BIGINT"));
}
#[test]
fn test_column_default_to_sql_escaping() {
let default_text = ColumnDefault::Text("O'Reilly".to_string());
assert_eq!(default_text.to_sql(), "'O''Reilly'");
}
#[test]
fn test_validate_identifier_multiple_dots() {
assert!(validate_identifier("table.column").is_ok()); assert!(validate_identifier("schema.table.column").is_err()); }
#[test]
fn test_column_default_sql_rendering() {
assert_eq!(
ColumnDefault::CurrentTimestamp.to_sql(),
"CURRENT_TIMESTAMP"
);
assert_eq!(ColumnDefault::Null.to_sql(), "NULL");
assert_eq!(ColumnDefault::Integer(42).to_sql(), "42");
assert_eq!(ColumnDefault::Float(1.23).to_sql(), "1.23");
assert_eq!(ColumnDefault::Text("hello".to_string()).to_sql(), "'hello'");
assert_eq!(ColumnDefault::Text("it's".to_string()).to_sql(), "'it''s'");
}
#[test]
fn test_join_clause_on_eq_binds_value() {
let mut jc = JoinClause::new("orders");
jc.on_eq("orders.user_id", 42i32);
assert_eq!(jc.to_sql(), "orders.user_id = ?");
assert_eq!(jc.bindings.len(), 1);
}
#[test]
fn test_join_clause_multiple_conditions() {
let mut jc = JoinClause::new("posts");
jc.on("posts.user_id", "=", "users.id");
jc.on("posts.status", ">", "users.min_status");
assert_eq!(
jc.to_sql(),
"posts.user_id = users.id AND posts.status > users.min_status"
);
}
#[test]
fn test_column_builder_methods() {
let mut col = Column::new("age", "INTEGER");
assert_eq!(col.name, "age");
assert_eq!(col.col_type, "INTEGER");
assert!(col.is_nullable); assert!(!col.is_primary_key);
assert!(!col.is_auto_increment);
assert_eq!(col.default_value, None);
col.not_null();
assert!(!col.is_nullable);
col.nullable();
assert!(col.is_nullable);
col.primary();
assert!(col.is_primary_key);
col.default(ColumnDefault::Integer(18));
assert_eq!(col.default_value, Some(ColumnDefault::Integer(18)));
}
#[test]
fn test_column_nullable_and_not_null_flips() {
let mut col = Column::new("status", "TEXT");
assert!(col.is_nullable);
col.not_null();
assert!(!col.is_nullable);
col.nullable();
assert!(col.is_nullable);
}
#[test]
fn test_blueprint_float_and_boolean_columns() {
let mut bp = Blueprint::new();
let col_float = bp.float("price");
assert_eq!(col_float.name, "price");
assert_eq!(col_float.col_type, "REAL");
assert!(col_float.is_nullable);
let col_bool = bp.boolean("is_active");
assert_eq!(col_bool.name, "is_active");
assert_eq!(col_bool.col_type, "INTEGER");
assert!(col_bool.is_nullable);
}
#[test]
fn test_blueprint_enum_column() {
let mut bp = Blueprint::new();
let col = bp.enum_col("status", vec!["Active", "Pending", "Canceled"]);
assert_eq!(col.name, "status");
assert_eq!(
col.col_type,
"TEXT CHECK(status IN ('Active', 'Pending', 'Canceled'))"
);
assert!(col.is_nullable);
}
struct AccountStatus;
impl DatabaseEnum for AccountStatus {
const TYPE_NAME: &'static str = "account_status";
const VARIANTS: &'static [&'static str] = &["awaiting_review", "owners_active"];
}
#[test]
fn native_enum_ddl_is_driver_specific_and_escaped() {
let mut blueprint = Blueprint::new();
blueprint.native_enum::<AccountStatus>("status").not_null();
assert!(
blueprint
.build_for_driver("postgres")
.expect("PostgreSQL enum DDL")
.contains("status \"account_status\" NOT NULL")
);
assert!(
blueprint
.build_for_driver("mysql")
.expect("MySQL enum DDL")
.contains("status ENUM('awaiting_review', 'owners_active') NOT NULL")
);
assert!(
blueprint
.build_for_driver("sqlite")
.expect("SQLite enum DDL")
.contains("status TEXT CHECK(status IN ('awaiting_review', 'owners_active')) NOT NULL")
);
}
struct ConflictingAccountStatus;
impl DatabaseEnum for ConflictingAccountStatus {
const TYPE_NAME: &'static str = "account_status";
const VARIANTS: &'static [&'static str] = &["Different"];
}
#[test]
fn native_enum_metadata_rejects_conflicting_named_types() {
let mut blueprint = Blueprint::new();
blueprint.native_enum::<AccountStatus>("primary_status");
blueprint.native_enum::<ConflictingAccountStatus>("secondary_status");
assert!(blueprint.postgres_enum_definitions().is_err());
}
#[test]
fn test_blueprint_boolean_column() {
let mut bp = Blueprint::new();
let col = bp.boolean("verified");
assert_eq!(col.name, "verified");
assert_eq!(col.col_type, "INTEGER");
assert!(col.is_nullable);
assert!(!col.is_primary_key);
assert!(!col.is_auto_increment);
assert_eq!(col.default_value, None);
}
#[tokio::test]
async fn test_db_migration_error_state_invalid_blueprint() {
let result = Schema::create("invalid; DROP TABLE users", |bp| {
bp.id();
})
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_drop_if_exists_invalid_table() {
let result = Schema::drop_if_exists("invalid; name").await;
assert!(result.is_err());
assert!(matches!(result, Err(crate::Error::Internal(_))));
}
#[test]
fn test_max_query_limit_and_timeout_globals() {
set_max_query_limit(50);
assert_eq!(get_max_query_limit(), Some(50));
set_max_query_limit(0);
assert_eq!(get_max_query_limit(), None);
set_query_timeout(10);
assert_eq!(
get_query_timeout(),
Some(std::time::Duration::from_secs(10))
);
set_query_timeout(0);
assert_eq!(get_query_timeout(), None);
}
#[tokio::test]
async fn test_run_artisan_entrypoint() {
let result = run_artisan(vec![], vec![]).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sail_install() {
let args = vec!["artisan".to_string(), "sail:install".to_string()];
let result = run_artisan_with_args(&args, vec![], vec![]).await;
assert!(result.is_ok());
let content = std::fs::read_to_string("docker-compose.yml").unwrap();
assert!(content.contains("postgres:15"));
assert!(content.contains("redis:alpine"));
std::fs::remove_file("docker-compose.yml").unwrap();
}