#![cfg(not(any(feature = "strict-sqlite", feature = "strict-mysql")))]
mod support;
mod driver_contract;
use rullst_orm::schema::{Blueprint, Schema};
use rullst_orm::{FromRow, Orm};
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
#[derive(Debug, Clone, FromRow, Orm)]
#[orm(table = "pg_users", auditable)]
struct User {
pub id: i32,
pub name: String,
pub email: String,
}
#[derive(Debug, Clone, FromRow, Orm)]
#[orm(table = "pg_tenant_posts", tenant_column = "tenant_id")]
struct TenantPost {
pub id: i32,
pub tenant_id: String,
pub author_id: i32,
}
#[derive(Debug, Clone, FromRow, Orm)]
#[orm(table = "pg_tenant_comments", tenant_column = "tenant_id")]
struct TenantComment {
pub id: i32,
pub tenant_id: String,
pub post_id: i32,
pub status: String,
}
#[derive(rullst_orm::Enum, Debug, Clone, Copy, PartialEq, Eq)]
#[rullst_enum(type_name = "pg_account_status", rename_all = "snake_case")]
enum AccountStatus {
AwaitingReview,
Active,
}
#[cfg(feature = "strict-postgres")]
#[derive(Debug, Clone, FromRow, Orm)]
#[orm(table = "pg_native_enum_accounts")]
struct NativeEnumAccount {
pub id: i32,
pub status: AccountStatus,
pub previous: Option<AccountStatus>,
}
#[cfg(feature = "strict-postgres")]
#[derive(rullst_orm::Enum, Debug, Clone, Copy, PartialEq, Eq)]
#[rullst_enum(type_name = "pg_tx_account_status", rename_all = "snake_case")]
enum TransactionalAccountStatus {
Pending,
Settled,
}
#[cfg(feature = "strict-postgres")]
struct ConflictingAccountStatus;
#[cfg(feature = "strict-postgres")]
impl rullst_orm::DatabaseEnum for ConflictingAccountStatus {
const TYPE_NAME: &'static str = "pg_account_status";
const VARIANTS: &'static [&'static str] = &["retired"];
}
#[tokio::test]
async fn test_matrix_postgres_crud() {
let container = match Postgres::default().start().await {
Ok(c) => c,
Err(e) => {
support::handle_container_start_error("PostgreSQL", e);
return;
}
};
let host_ip = container.get_host().await.expect("Failed to get host IP");
let host_port = container
.get_host_port_ipv4(5432)
.await
.expect("Failed to get port");
let connection_string = format!(
"postgres://postgres:postgres@{}:{}/postgres",
host_ip, host_port
);
Orm::init(&connection_string)
.await
.expect("Orm::init should succeed with Postgres");
Schema::create("pg_users", |t: &mut Blueprint| {
t.id();
t.string("name").not_null();
t.string("email").not_null();
})
.await
.expect("create pg_users");
rullst_orm::audit::create_audit_table()
.await
.expect("create PostgreSQL audit table");
let audit_context =
rullst_orm::audit::AuditContext::system("postgres-matrix").expect("valid audit context");
let mut user = User {
id: 0,
name: "Alice PG".into(),
email: "alice@pg.com".into(),
};
rullst_orm::audit::with_audit_context(audit_context.clone(), user.save())
.await
.expect("save new user to postgres");
assert!(user.id > 0, "id must be assigned after save (RETURNING id)");
let found = User::find(user.id)
.await
.expect("find query executed")
.expect("user exists");
assert_eq!(found.name, "Alice PG");
user.name = "Alice PG Updated".into();
rullst_orm::audit::with_audit_context(audit_context.clone(), user.save())
.await
.expect("update user in postgres");
let updated = User::find(user.id).await.unwrap().unwrap();
assert_eq!(updated.name, "Alice PG Updated");
let audit_id: (i32,) = sqlx::query_as(
"SELECT id FROM rullst_audits WHERE model_type = $1 AND model_id = $2 AND event = 'updated' ORDER BY id DESC LIMIT 1",
)
.bind("pg_users")
.bind(user.id)
.fetch_one(Orm::pool().expect("PostgreSQL pool"))
.await
.expect("read PostgreSQL audit revision");
user = rullst_orm::audit::with_audit_context(
audit_context.clone(),
user.restore_revision(audit_id.0, "matrix rollback"),
)
.await
.expect("restore PostgreSQL revision");
assert_eq!(user.name, "Alice PG");
rullst_orm::audit::with_audit_context(audit_context, user.delete())
.await
.expect("delete executed");
let not_found = User::find(user.id).await.unwrap();
assert!(not_found.is_none());
support::exercise_outbox().await;
support::exercise_large_audit_payload().await;
exercise_tenant_subqueries().await;
#[cfg(feature = "strict-postgres")]
exercise_native_enum().await;
#[cfg(not(feature = "strict-postgres"))]
exercise_dynamic_pool_enum_refusal().await;
driver_contract::exercise().await;
}
async fn exercise_tenant_subqueries() {
let pool = Orm::pool().expect("PostgreSQL pool");
for statement in [
"CREATE TABLE pg_tenant_posts (id SERIAL PRIMARY KEY, tenant_id TEXT NOT NULL, author_id INTEGER NOT NULL)",
"CREATE TABLE pg_tenant_comments (id SERIAL PRIMARY KEY, tenant_id TEXT NOT NULL, post_id INTEGER NOT NULL, status TEXT NOT NULL)",
"CREATE TABLE pg_tenant_authors (id INTEGER PRIMARY KEY, region TEXT NOT NULL)",
"INSERT INTO pg_tenant_posts (id, tenant_id, author_id) VALUES (1, 'acme', 1), (2, 'acme', 2), (3, 'other', 1), (4, 'acme', 1)",
"INSERT INTO pg_tenant_comments (tenant_id, post_id, status) VALUES ('acme', 1, 'open'), ('acme', 2, 'open'), ('other', 3, 'open'), ('acme', 4, 'published'), ('other', 4, 'open')",
"INSERT INTO pg_tenant_authors (id, region) VALUES (1, 'eu'), (2, 'us')",
] {
sqlx::query(statement)
.execute(pool)
.await
.expect("seed PostgreSQL tenant subquery fixture");
}
let open_comment = || {
TenantComment::query()
.where_column("pg_tenant_comments.post_id", "pg_tenant_posts.id")
.where_eq("status", "open")
};
let (ids, count, deleted, remaining) = rullst_orm::with_tenant("acme", async {
let query = TenantPost::query()
.with_cte(
"published_comments",
TenantComment::query().where_eq("status", "published"),
)
.with_cte(
"open_comments",
TenantComment::query()
.where_exists(TenantComment::query().where_eq("status", "open"))
.where_eq("status", "open"),
)
.join_constrained("pg_tenant_authors", |join| {
join.on("pg_tenant_authors.id", "=", "pg_tenant_posts.author_id")
.on_eq("pg_tenant_authors.region", "eu")
})
.where_exists(open_comment());
let ids = query
.clone()
.order_by("pg_tenant_posts.id")
.pluck_i32("pg_tenant_posts.id")
.await
.expect("PostgreSQL tenant EXISTS with CTEs and JOIN");
let count = query.count().await.expect("PostgreSQL ordered count");
let deleted = TenantPost::query()
.where_exists(open_comment())
.delete_all()
.await
.expect("PostgreSQL delete_all with an embedded subquery");
let remaining = TenantPost::query()
.order_by("id")
.pluck_i32("id")
.await
.expect("remaining PostgreSQL tenant rows");
(ids, count, deleted, remaining)
})
.await;
assert_eq!(ids, vec![1]);
assert_eq!(count, 1);
assert_eq!(deleted, 2);
assert_eq!(remaining, vec![4]);
}
#[cfg(feature = "strict-postgres")]
async fn exercise_native_enum() {
Schema::create("pg_native_enum_accounts", |table: &mut Blueprint| {
table.id();
table.native_enum::<AccountStatus>("status").not_null();
})
.await
.expect("PostgreSQL named enum schema should be created");
sqlx::query("INSERT INTO pg_native_enum_accounts (status) VALUES ($1)")
.bind(AccountStatus::AwaitingReview)
.execute(Orm::pool().expect("PostgreSQL pool"))
.await
.expect("PostgreSQL enum should encode");
let stored = sqlx::query_scalar::<_, AccountStatus>(
"SELECT status FROM pg_native_enum_accounts WHERE id = 1",
)
.fetch_one(Orm::pool().expect("PostgreSQL pool"))
.await
.expect("PostgreSQL enum should decode");
assert_eq!(stored, AccountStatus::AwaitingReview);
exercise_native_enum_filters().await;
let drift = Schema::create("pg_native_enum_conflict", |table: &mut Blueprint| {
table.id();
table
.native_enum::<ConflictingAccountStatus>("status")
.not_null();
})
.await;
assert!(
matches!(drift, Err(rullst_orm::Error::Validation(_))),
"an existing PostgreSQL enum with different labels must fail closed"
);
let rolled_back = rullst_orm::Orm::transaction(|_| {
Box::pin(async {
Schema::create("pg_tx_enum_accounts", |table: &mut Blueprint| {
table.id();
table
.native_enum::<TransactionalAccountStatus>("status")
.not_null();
})
.await?;
Err::<(), rullst_orm::Error>(rullst_orm::Error::Validation(
"roll back the enum schema".to_string(),
))
})
})
.await;
assert!(rolled_back.is_err());
assert_eq!(
postgres_type_count("pg_tx_account_status").await,
0,
"a rolled-back Schema::create must not leave its enum type behind"
);
let dropped = tokio::time::timeout(
std::time::Duration::from_secs(20),
rullst_orm::Orm::transaction(|_| {
Box::pin(async {
Schema::drop_if_exists("pg_native_enum_accounts").await?;
Schema::drop_native_enum::<AccountStatus>().await
})
}),
)
.await
.expect("dropping the enum after its table in one transaction must not hang");
dropped.expect("PostgreSQL enum table and type should be dropped together");
assert_eq!(postgres_type_count("pg_account_status").await, 0);
}
#[cfg(feature = "strict-postgres")]
async fn exercise_native_enum_filters() {
sqlx::query("ALTER TABLE pg_native_enum_accounts ADD COLUMN previous pg_account_status")
.execute(Orm::pool().expect("PostgreSQL pool"))
.await
.expect("add nullable enum column");
let mut active = NativeEnumAccount {
id: 0,
status: AccountStatus::Active,
previous: Some(AccountStatus::AwaitingReview),
};
active.save().await.expect("save a model with enum columns");
let by_eq = NativeEnumAccount::query()
.where_eq("status", AccountStatus::Active)
.get()
.await
.expect("where_eq on a named enum column");
assert_eq!(
by_eq.iter().map(|row| row.id).collect::<Vec<_>>(),
[active.id]
);
let by_helper = NativeEnumAccount::query()
.where_status(AccountStatus::AwaitingReview)
.get()
.await
.expect("generated where_<column> on a named enum column");
assert_eq!(by_helper.len(), 1);
let by_in = NativeEnumAccount::query()
.where_in(
"pg_native_enum_accounts.status",
vec![AccountStatus::Active, AccountStatus::AwaitingReview],
)
.order_by("id")
.count()
.await
.expect("qualified where_in on a named enum column");
assert_eq!(by_in, 2);
let by_previous = NativeEnumAccount::query()
.where_eq("previous", AccountStatus::AwaitingReview)
.or_where_not_eq("status", AccountStatus::Active)
.count()
.await
.expect("nullable enum and OR filters");
assert_eq!(by_previous, 2);
let ordered = NativeEnumAccount::query()
.where_gt("status", AccountStatus::AwaitingReview)
.pluck_i32("id")
.await
.expect("enum ordering comparison");
assert_eq!(ordered, [active.id]);
active.delete().await.expect("delete the enum row");
}
#[cfg(feature = "strict-postgres")]
async fn postgres_type_count(type_name: &str) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM pg_type WHERE typname = $1")
.bind(type_name)
.fetch_one(Orm::pool().expect("PostgreSQL pool"))
.await
.expect("inspect PostgreSQL types")
}
#[cfg(not(feature = "strict-postgres"))]
async fn exercise_dynamic_pool_enum_refusal() {
let unsupported = Schema::create("pg_any_native_enum", |table: &mut Blueprint| {
table.id();
table.native_enum::<AccountStatus>("status").not_null();
})
.await;
assert!(
matches!(
unsupported,
Err(rullst_orm::Error::Validation(message))
if message.contains("strict-postgres")
),
"SQLx Any must fail before creating an unreadable PostgreSQL custom type"
);
}